自己动手实现深度学习框架-3 自动分批训练, 缓解过拟合
代码仓库: https://github.com/brandonlyg/cute-dl
目标
- 为Session类增加自动分批训练模型的功能, 使框架更好用。
- 新增缓解过拟合的算法: L2正则化, 随机丢弃。
实现自动分批训练
设计方案
- 增加Dataset类负责管理数据集, 自动对数据分批。
- 在Session类中增加fit方法, 从Dataset得到数据, 使用事件机制告诉外界训练情况, 最后返回一个训练历史记录。
- 增加FitListener类, 用于监听fit方法训练过程中触发的事件。
fit方法
定义:
fit(self, data, epochs, **kargs)
data: 训练数据集Dataset对象。
epochs: 训练轮数, 把data中的每一批数据遍历训练一次称为一轮。
kargs:
val_data: 验证数据集。
val_epochs: 执行验证的训练轮数. 每val_epochs轮训练验证一次。
val_steps: 执行验证的训练步数. 每val_steps步训练验证一次. 只有val_steps>0才有效, 优先级高于val_epochs。
listeners: 事件监听器FitListener对象列表.
fit方法触发的事件
- epoch_start: 每轮训练开始时触发。
- epoch_end: 每轮训练结束时触发。
- val_start: 每次执行验证时触发。
- val_end: 每次执行验证结束时触发。
训练过程中, fit方法会把触发的事件派发到所有的FitListener对象, FitListener对象自己决定处理或忽略。
训练历史记录(history)
? ? ? ? history的格式:
{ ‘loss‘: [], ‘val_loss‘: [], ‘steps‘: [], ‘val_pred‘: darray, ‘cost_time‘: float }
- loss: 记录训练误差。
- val_loss: 记录验证误差。loss会和val_loss同步记录。
- steps: 每个误差记录对应的训练步数。
- val_pred: 最后一次执行验证时模型使用验证数据集预测的结果。
- cost_time: 整个训练过程花费的时间(s)。
代码
fit方法实现
? ? ? ? 代码文件: cutedl/session.py。
? ? ? ? fit方法比较复杂, 先看主干代码:
#初始化训练历史数据结构 history = { ‘loss‘: [], ‘val_loss‘: [], ‘steps‘: [], ‘val_pred‘: None, ‘cost_time‘: 0 } #打开训练开关, 当调用stop_fit方法后会关闭这个开关, 停止训练。 self.__fit_switch = True #得到参数 val_data = kargs.get(‘val_data‘) val_epochs = kargs.get(‘val_epochs‘, 1) val_steps = kargs.get(‘val_steps‘, 0) listeners = kargs.get(‘listeners‘, []) if val_data is None: history[‘val_loss‘] = None #计算将会训练的最大步数 if val_epochs <= 0 or val_epochs >= epochs: val_epochs = 1 if val_steps <= 0: val_steps = val_epochs * data.batch_count #开始训练 step = 0 history[‘cost_time‘] = time.time() for epoch in range(epochs): if not self.__fit_switch: break #触发并派发事件 event_dispatch("epoch_start") for batch_x, batch_y in data.as_iterator(): if not self.__fit_switch: break #pdb.set_trace() loss = self.batch_train(batch_x, batch_y) step += 1 if step % val_steps == 0: #使用验证数据集验证模型 event_dispatch("val_start") val_loss, val_pred = validation() record(loss, val_loss, val_pred, step) event_dispatch("val_end") #显示训练进度 display_progress(epoch+1, epochs, step, val_steps, loss, val_loss) else: display_progress(epoch+1, epochs, step, val_steps, loss) event_dispatch("epoch_end") #记录训练耗时 history[‘cost_time‘] = time.time() - history[‘cost_time‘] return history
? ? ? ? 主干代码中使用了一些局部函数, 这些局部函数每个都是实现了一个小功能。
? ? ? ? 派发事件:
def event_dispatch(event): #pdb.set_trace() for listener in listeners: listener(event, history)
? ? ? ? 执行验证:
def validation(): if val_data is None: return None, None val_pred = None #保存所有的预测结果 losses = [] #保存所有的损失值 #分批验证 for batch_x, batch_y in val_data.as_iterator(): #pdb.set_trace() y_pred = self.__model.predict(batch_x) loss = self.__loss(batch_y, y_pred) losses.append(loss) if val_pred is None: val_pred = y_pred else: val_pred = np.vstach((val_pred, y_pred)) #计算平均损失 loss = np.mean(np.array(losses)) return loss, val_pred
? ? ? ? 记录训练历史:
def record(loss, val_loss, val_pred, step): history[‘loss‘].append(loss) history[‘steps‘].append(step) if history[‘val_loss‘] is not None and val_loss is not None : history[‘val_loss‘].append(val_loss) history[‘val_pred‘] = val_pred
? ? ? ? 显示训练进度:
def display_progress(epoch, epochs, step, steps, loss, val_loss=-1): prog = (step % steps)/steps w = 20 str_epochs = ("%0"+str(len(str(epochs)))+"d/%d")%(epoch, epochs) txt = (">"*(int(prog * w))) + (" "*w) txt = txt[:w] if val_loss < 0: txt = txt + (" loss=%f "%loss) print("%s %s"%(str_epochs, txt), end=‘\r‘) else: txt = "loss=%f, val_loss=%f"%(loss, val_loss) print("") print("%s %s\n"%(str_epochs, txt))
实现L2正则化参数优化器
设计方案
- 增强Optimizer类的功能, 能够自己匹配要更新的参数。
- 给出L2正则化算法的Optimizer实现。
- 在Session类中增加对广义优化器的支持(L2优化器就是广义优化器)。
数学原理
? ? ? ? 设模型每一层的损失函数为:
? ? ? ? X是数据, W是权重参数,b是偏移量参数. L2算法是在原损失函数上加上W范数平方的衰减量, 得到一个新的损失函数:
? ? ? ? λ是衰减率, 是一个相当于学习率的超参数。对于一个模型来说, 只有输出层的损失函数是明确知道的, 其他层是不明确的。不过没关系, 更新参数是在反向传播阶段,这个时候需要的是梯度, 并不关心原函数的形式, 新损失函数的梯度为:
? ? ? ? 其中
? ? ? ? 可以在反向传播时候得到. 在梯度下降法训练模型时, 更新参数的表达式变成:
? ? ? ? 这个表达式的含义是: 在使用学习率更新参数之前,先把参数(W的范数)缩小到原来的(1-λ)倍。
代码
增强Optimizer功能
? ? ? ? 代码文件: cutedl/optimizer.py
? ? ? ? 修改__call__代码:
def __call__(self, model): params = self.match(model) for p in params: self.update_param(model, p)
match方法用来把名字匹配的参数过滤出来。
update_param方法实现实际的更新参数操作, 由子类实现。
? ? ? ? match实现:
‘‘‘ 得到名字匹配pattern的参数 ‘‘‘ def match(self, model): params = [] rep = re.compile(self.pattern) for ly in model.layer_iterator(): for p in ly.params: if rep.match(p.name) is None: continue params.append(p) return params
? ? ? ? 这个方法使用正则表达式通过参数名匹配参数, 并返回匹配的参数列表。pattern是正则表达式属性, 子类可以通过覆盖这个属性, 改变匹配行为。
实现L2正则化优化器
‘‘‘ L2 正则化 ‘‘‘ class L2(Optimizer): ‘‘‘ damping 参数衰减率 ‘‘‘ def __init__(self, damping): self.__damping = damping def update_param(self, model, param): #pdb.set_trace() param.value = (1 - self.__damping) * param.value
在Session中支持广义参数优化器
? ? ? ? 代码文件: cutedl/session.py。
? ? ? ? 首先为__init__ 方法添加参数:
‘‘‘ genoptms: list[Optimizer]对象, 广义参数优化器列表, 列表中的优化器将会在optimizer之前按顺序执行 ‘‘‘ def __init__(self, model, loss, optimizer, genoptms=None): self.__genoptms = genoptms
? ? ? ? 然后在batch_train方法中调用优化器:
#执行广义优化器更新参数 if self.__genoptms is not None: for optm in self.__genoptms: optm(self.__model)
实现随机丢弃层: Dropout
数学原理
? ? ? ? 向前传播的函数:
? ? ? ? p是我们要给出的常数。算法使用p构造随机变量A, 使得A=1的概率为p, A=0的概率为1-p. 对这个函数的直观解释是: A将有1-p的概率被丢弃掉(置为0), p的概率被保留, 如果被保留, 它将会被拉伸1/p倍。 这个函数有一个很有用的性质, 它的输入和输出的均值不变:
? ? ? ? 反向传播的梯度为:
代码
? ? ? ? 代码文件: nn_layers.py。
? ? ? ? Dropout类实现了随机丢弃算法。向前传播实现:
def forward(self, in_batch, training=False): kp = self.__keep_prob #pdb.set_trace() if not training or kp <= 0 or kp>=1: return in_batch #生成[0, 1)之间的均价分布 tmp = np.random.uniform(size=in_batch.shape) #保留/丢弃索引 mark = (tmp <= kp).astype(int) #丢弃数据, 并拉伸保留数据 out = (mark * in_batch)/kp self.__mark = mark return out
? ? ? ? 随机丢弃层传入的参数是keep_prob保留概率, 这意味这丢弃的概率为1 - keep_prob. 只有处于训练状态且0<keep_prob<1才执行丢弃操作。代码中的变量mark就是用保留概率构造随机变量, 它服从参数为keep_prob的伯努利分布。
? ? ? ? 反向传播实现:
def backward(self, gradient): #pdb.set_trace() if self.__mark is None: return gradient out = (self.__mark * gradient)/self.__keep_prob return out
验证
? ? ? ? 目前阶段所需要的代码已经完成,现在我们来进行验证,验证代码位于: examples/mlp/linear-regression-1.py。
对比基准
? ? ? ? 首先我们来构造一个欠拟合模型作为对比基准。
‘‘‘ 过拟合对比基准 ‘‘‘ def fit0(): print("fit0") model = Model([ nn.Dense(128, inshape=1, activation=‘relu‘), nn.Dense(256, activation=‘relu‘), nn.Dense(1) ]) model.assemble() sess = Session(model, loss=losses.Mse(), optimizer = optimizers.Fixed(), ) history = sess.fit(ds, 200000, val_data=val_ds, val_epochs=1000, listeners=[ FitListener(‘val_end‘, callback=lambda h:on_val_end(sess, h)) ] ) fit_report(history, report_path+‘00.png‘, 10)
? ? ? ? 可以看到这里不再需要自己写训练函数, 直接调用fit方法即可实现自动训练。on_val_end函数监听val_end事件, 它的功能是在满是条件时调用Session的stop_fit方法停止训练, 这里停止训练的条件是: 最初的10次验证过后, 检查每次验证的val_loss值, 如果连续10次没有变得更小就停止训练。
? ? ? ? 拟合报告:
使用L2优化器缓解过拟合
‘‘‘ 使用L2正则化缓解过拟合 ‘‘‘ def fit1(): print("fit1") model = Model([ nn.Dense(128, inshape=1, activation=‘relu‘), nn.Dense(256, activation=‘relu‘), nn.Dense(1) ]) model.assemble() sess = Session(model, loss=losses.Mse(), optimizer = optimizers.Fixed(), #L2正则化 genoptms = [optimizers.L2(0.00005)] ) history = sess.fit(ds, 200000, val_data=val_ds, val_epochs=1000, listeners=[ FitListener(‘val_end‘, callback=lambda h:on_val_end(sess, h)) ] ) fit_report(history, report_path+‘01.png‘, 10)
? ? ? ? 拟合报告:
? ? ? ? 从训练损失值图像上看有明显的缓解迹象。
使用Dropout层缓解过拟合
‘‘‘ 使用dropout缓解过拟合 ‘‘‘ def fit2(): print("fit2") model = Model([ nn.Dense(128, inshape=1, activation=‘relu‘), nn.Dense(256, activation=‘relu‘), nn.Dropout(0.80), #0.8的保留概率 nn.Dense(1) ]) model.assemble() sess = Session(model, loss=losses.Mse(), optimizer = optimizers.Fixed(), ) history = sess.fit(ds, 200000, val_data=val_ds, val_epochs=1000, listeners=[ FitListener(‘val_end‘, callback=lambda h:on_val_end(sess, h)) ] ) fit_report(history, report_path+‘02.png‘, 15)
? ? ? ? 拟合报告:
? ? ? ? 从训练损失值图像上随机丢弃的效果更好一些。
总结
? ? ? ? 验证结果表明, cute-dl目前可以用很少代码实现模型的自动分批训练, 和linear-regression.py相比, linear-regression-1.py中已经不需要关注具体的训练过程了, 并且能够得到基本训练历史记录。另外, L2正则化优化器和Dropout层也能有效地缓解过拟合。 本阶段目标基本达成。
? ? ? ? 到目前为止, 用来验证框架的是一个线性回归任务, 数据集是从一个二次函数采样得到, 这个任务本质上是训练模型预测连续值。但是在深度学习领域,还要求模型能够预测离散值,即能够执行分类任务。下个阶段, 将会给框架添加新的损失函数, 使之能够支持分类任务, 并讨论这些损失函数的数学性质。