体验完了“浅度”神经网络后,我们终于等到了这门课的正题——深度神经网络了。
其实这节课并没有引入太多新的知识,只是把上节课的2层网络拓展成了L层网络。对于编程能力强的同学(或者认真研究了我上节课的编程实战代码的同学,嘿嘿嘿),学完了上节课的内容后,就已经有能力完成这节课的作业了。
所谓深度神经网络,只是神经网络的隐藏层数量比较多而已,它的本质结构和前两课中的神经网络是一样的。让我们再复习一下神经网络中的标记:
L L L表示网络的层数。
在这个网络中, L = 4 L=4 L=4。(注意:输入层并不计入层数,但可以用第“0”层称呼输入层)
上标中括号的标号 l ( l ∈ [ 0 , L ] ) l (l \in [0, L]) l(l∈[0,L])表示和第 l l l层相关的数据。比如, n [ l ] n^{[l]} n[l]是神经网络第 l l l层的神经元数(即每层输出向量的长度)。
这幅图里 n [ 1 ] = 5 n^{[1]}=5 n[1]=5, n [ 3 ] = 3 n^{[3]}=3 n[3]=3,以此类推。值得注意的是, n [ 0 ] = n x = 3 n^{[0]}=n_x=3 n[0]=nx=3。回想第二课的知识, n x n_x nx是输入向量的长度。
再比如, a [ l ] a^{[l]} a[l]是第 l l l层的输出向量。 a [ l ] = g [ l ] ( z [ l ] ) a^{[l]}=g^{[l]}(z^{[l]}) a[l]=g[l](z[l]),其中 g [ l ] g^{[l]} g[l]是第 l l l层的激活函数, z [ l ] z^{[l]} z[l]是第 l l l层的中间运算结果。 W [ l ] , b [ l ] W[l], b[l] W[l],b[l]是第 l l l层的参数。
和上节课的单隐层神经网络类似,对于 L L L层的网络,我们如下方法对单样本做前向传播(推理):
a [ l ] ← g [ l ] ( W [ l ] a [ l − 1 ] + b [ l ] ) f o r l ∈ [ 1 , 2 , . . . L ] \begin{aligned} a^{[l]} & \gets g^{[l]}(W^{[l]}a^{[l - 1]}+b^{[l]}) \\ for \ l & \in [1, 2, ... L] \end{aligned} a[l]for l←g[l](W[l]a[l−1]+b[l])∈[1,2,...L]
其中,输入输出分别为: x = a [ 0 ] , y ^ = a [ L ] x=a^{[0]}, \hat{y}=a^{[L]} x=a[0],y^=a[L]。
当我们考虑全体样本 X , Y X, Y X,Y时,上面的算式可以写成:
A [ l ] ← g [ l ] ( W [ l ] A [ l − 1 ] + b [ l ] ) f o r l ∈ [ 1 , 2 , . . . L ] \begin{aligned} A^{[l]} & \gets g^{[l]}(W^{[l]}A^{[l - 1]}+b^{[l]}) \\ for \ l & \in [1, 2, ... L] \end{aligned} A[l]for l←g[l](W[l]A[l−1]+b[l])∈[1,2,...L]
其中,输入输出分别为: X = A [ 0 ] , Y ^ = A [ L ] X=A^{[0]}, \hat{Y}=A^{[L]} X=A[0],Y^=A[L]。
从公式上看,使用向量化计算全体样本只是把小写字母换成了大写字母而已。用代码实现时,我们甚至也只需要照搬上述公式就行。但我们要记住,全体样本是把每个样本以列向量的形式横向堆叠起来,堆成了一个矩阵。我们心中对 X , Y X, Y X,Y的矩阵形状要有数。
在实现深度神经网络时,我们不可避免地引入了一个新的for循环:循环遍历网络的每一层。这个for循环是无法消除的。要记住,我们要消除的for循环,只有向量化计算中的for循环。它们之所以能被消除,是因为向量化计算可以使用并行加速,而不是for循环本身有问题。我们甚至可以把“向量化加法”、“向量化乘法”这些运算视为最小的运算单元。而在写其他代码时,不用刻意去规避for循环。
参数矩阵的形状是: W [ l ] : ( n [ l ] , n [ l − 1 ] ) , b [ l ] : ( n [ l ] , 1 ) W^{[l]}: (n^{[l]} , n^{[l-1]}), b^{[l]} : (n^{[l]} , 1) W[l]:(n[l],n[l−1]),b[l]:(n[l],1)
每一层的输入输出矩阵形状是: A [ l ] : ( n [ l ] , m ) A^{[l]} : (n^{[l]} , m) A[l]:(n[l],m)
如果忘了 W W W的形状,就拿矩阵乘法形状规则 ( a , b ) ⋅ ( b , c ) = ( a , c ) (a , b) \cdot (b , c)=(a , c) (a,b)⋅(b,c)=(a,c)推一下。
某张量的梯度张量与其形状相同。比如 d W dW dW的形状也是 ( n [ l ] , n [ l − 1 ] ) (n^{[l]} , n^{[l-1]}) (n[l],n[l−1])。
这里给出一种不严谨、出于直觉的解释:网络中越靠前的层,捕捉的信息越初级;越深的层,捕捉的信息越高级。比如对于上图所示的人脸检测网络,网络的第一层可能识别的是图片的边缘,第二层识别的是人的五官,第三层识别的是整个人脸。更深的网络有助于捕捉更高层次的特征。
另外,从计算复杂度的角度来看,用更深的网络,而不是在同一层网络里叠加更多神经元,往往能更轻易地拟合出一个函数。比如要拟合函数a+b+c+d,我们可以先算(a+b)和(c+d),再算(a+b)+(c+d)。这只需要2“层”计算。如果把上面4个数相加变成 n n n个数相加,我们只需要构造 l o g n logn logn层网络。但如果用单层网络拟合 n n n个数相加,网络可能要尝试尝试 a , b , a + b , c , a + b + c , . . . a, b, a+b, c, a+b+c, ... a,b,a+b,c,a+b+c,...这一共 O ( 2 n ) O(2^n) O(2n)种公式,需要在1层里放 O ( 2 n ) O(2^n) O(2n)个神经元。
这一章反正不是严谨的科学证明,内容听听就好。深度神经网络好不好用,究竟用多少层的网络,这些决定都取决于实际的问题。只不过大多数任务用深度神经网络实现都能生效。
前两节课,我们的网络只有1层或2层。我们或许可以直接写出它们的训练步骤。现在,对于 L L L层的网络,我们必须系统化地写出它们的训练流程。
首先是前向传播;
A [ 0 ] = X A [ 1 ] ← g [ 1 ] ( Z [ 1 ] ) = g [ 1 ] ( W [ 1 ] A [ 0 ] + b [ 1 ] ) . . . A [ l ] ← g [ l ] ( Z [ l ] ) = g [ l ] ( W [ l ] A [ l − 1 ] + b [ l ] ) \begin{aligned} A^{[0]} & = X \\ A^{[1]} & \gets g^{[1]}(Z^{[1]})=g^{[1]}(W^{[1]}A^{[0]}+b^{[1]}) \\ ... & \\ A^{[l]} & \gets g^{[l]}(Z^{[l]})=g^{[l]}(W^{[l]}A^{[l - 1]}+b^{[l]}) \end{aligned} A[0]A[1]...A[l]=X←g[1](Z[1])=g[1](W[1]A[0]+b[1])←g[l](Z[l])=g[l](W[l]A[l−1]+b[l])
在前向传播时,我们要缓存(cache)一些临时变量,以辅助反向传播:
A [ 0 ] = X A [ 1 ] ← g [ 1 ] ( Z [ 1 ] ) c a c h e A [ 1 ] , Z [ 1 ] , W [ 1 ] , b [ 1 ] . . . A [ l ] ← g [ l ] ( Z [ l ] ) c a c h e A [ l ] , Z [ l ] , W [ l ] , b [ l ] \begin{aligned} A^{[0]} & = X \\ A^{[1]} & \gets g^{[1]}(Z^{[1]}) \ cache \ A^{[1]}, Z^{[1]},W^{[1]}, b^{[1]} \\ ... & \\ A^{[l]} & \gets g^{[l]}(Z^{[l]}) \ cache \ A^{[l]}, Z^{[l]},W^{[l]}, b^{[l]} \end{aligned} A[0]A[1]...A[l]=X←g[1](Z[1]) cache A[1],Z[1],W[1],b[1]←g[l](Z[l]) cache A[l],Z[l],W[l],b[l]
反向传播则按下面的步骤计算(注意观察被缓存的变量是怎么使用的):
d A [ L ] = − y a + 1 − y 1 − a . . . d Z [ l ] ← d A [ l ] ∗ g [ l ] ′ ( Z [ l ] ) u s e Z [ l ] d W [ l ] ← 1 m d Z [ l ] A [ l − 1 ] T u s e A [ l ] d b [ l ] ← n p . m e a n ( d Z [ l ] , a x i s = 1 , k e e p d i m s = T r u e ) u p d a t e W [ l ] , b [ l ] u s e W [ l ] , b [ l ] d A [ l − 1 ] ← W [ l ] T d Z [ l ] u s e W [ l ] . . . \begin{aligned} dA^{[L]} & = -\frac{y}{a} + \frac{1 - y}{1 - a} \\ ... & \\ dZ^{[l]} & \gets dA^{[l]} \ast g^{[l]'}(Z^{[l]}) &use \ Z^{[l]} \\ dW^{[l]} & \gets \frac{1}{m}dZ^{[l]} A^{[l-1]T} &use \ A^{[l]} \\ db^{[l]} & \gets np.mean(dZ^{[l]}, axis=1, keepdims=True) \\ update & \ W^{[l]}, b^{[l]} &use \ W^{[l]}, b^{[l]}\\ dA^{[l-1]} & \gets W^{[l]T}dZ^{[l]} &use \ W^{[l]} \\ ... \end{aligned} dA[L]...dZ[l]dW[l]db[l]updatedA[l−1]...=−ay+1−a1−y←dA[l]∗g[l]′(Z[l])←m1dZ[l]A[l−1]T←np.mean(dZ[l],axis=1,keepdims=True) W[l],b[l]←W[l]TdZ[l]use Z[l]use A[l]use W[l],b[l]use W[l]
上面的公式里,默认损失函数 L ( a , y ) = − ( y l o g a + ( 1 − y ) l o g ( 1 − a ) ) L(a, y) = -(y \ loga + (1-y) \ log(1-a)) L(a,y)=−(y loga+(1−y) log(1−a))
记不住公式没关系,编程的时候对着翻译就行。
从算法的角度来看,梯度下降法只需要用反向传播算法。我们这里之所以做一遍正向传播,是因为反向传播要用到正向传播的中间运算结果。从逻辑关系来看,是反向传播函数调用了正向传播函数,而不是“先正向传播,再反向传播”的并列关系,虽然编程时是用后者来表达。
之前,我们在不经意间就已经接触了“超参数”这个词,但一直没有对它下一个定义。现在,我们来正式介绍超参数这个概念,以及它和参数的关系。
对于我们之前的神经网络,参数包括:
这些参数和数学里的参数意义一样,表示函数的参数。
而超参数则包括:
我们直接从超参数的作用来给超参数下定义。超参数的取值会决定参数 W , b W, b W,b的取值,它们往往只参与训练,而不参与最后的推理计算。可以说,除了网络中要学习的参数外,网络中剩下的可以变动的数值,都是超参数。
一个简单区别超参数的方法是:超参数一般是我们手动调的。我们常说“调参”,说的是超参数。
吴恩达老师鼓励我们多尝试调参,对于不同的问题可以尝试不同的超参数。
生物的神经由“树突”,“轴突”等部分组成。生物信号会通过这些部分在神经里传播。神经网络的工作原理和生物神经的原理有那么一点类似。
但迄今为止,生物神经的原理还没有被破解。我们把神经网络当成一个 x → y x \to y x→y的映射就好了。
这一课主要是介绍编程实现的思路,没有过多的知识点:
如果大家对神经网络的前向传播或反向传播还有问题,欢迎去回顾上一篇笔记:https://zhouyifan.net/2022/05/23/DLS-note-3/。
说实话,这堂课的编程作业只能称为“体验写代码的感觉”,不能堂堂正正地称为“写程序”。整个框架都搭好了,每行输入输出的变量都给好了,只要填一填函数调用就行了。这样编程实在是不好玩,没有难度,学不到东西。
所以,是时候自己动手写代码了!
两周前,使用逻辑回归做小猫分类并不成功。看看这周换了“深度”神经网络后,我们能不能在小猫分类上取得更好的成绩。
class BaseRegressionModel(metaclass=abc.ABCMeta):
def __init__(self):
pass
@abc.abstractmethod
def forward(self, X: np.ndarray, train_mode=True) -> np.ndarray:
pass
@abc.abstractmethod
def backward(self, Y: np.ndarray) -> np.ndarray:
pass
@abc.abstractmethod
def gradient_descent(self, learning_rate: float) -> np.ndarray:
pass
@abc.abstractmethod
def save(self, filename: str):
pass
@abc.abstractmethod
def load(self, filename: str):
pass
def loss(self, Y: np.ndarray, Y_hat: np.ndarray) -> np.ndarray:
return np.mean(-(Y * np.log(Y_hat) + (1 - Y) * np.log(1 - Y_hat)))
def evaluate(self, X: np.ndarray, Y: np.ndarray, return_loss=False):
Y_hat = self.forward(X, train_mode=False)
Y_hat_predict = np.where(Y_hat > 0.5, 1, 0)
accuracy = np.mean(np.where(Y_hat_predict == Y, 1, 0))
if return_loss:
loss = self.loss(Y, Y_hat)
return accuracy, loss
else:
return accuracy
这次我们还是用基于这个通用分类器类编写代码。相比上篇笔记中的代码,这个类有以下改动:
save
,load
用于保存和读取模型。evaluate
不再直接输出结果,而是返回一些评测结果,供调用其的函数使用。注意,这次我们不仅算了测试集上的准确率,还算了测试集上的损失函数。def train(model: BaseRegressionModel,
X,
Y,
step,
learning_rate,
print_interval=100,
test_X=None,
test_Y=None):
for s in range(step):
Y_hat = model.forward(X)
model.backward(Y)
model.gradient_descent(learning_rate)
if s % print_interval == 0:
loss = model.loss(Y, Y_hat)
print(f"Step: {s}")
print(f"Train loss: {loss}")
if test_X is not None and test_Y is not None:
accuracy, loss = model.evaluate(test_X,
test_Y,
return_loss=True)
print(f"Test loss: {loss}")
print(f"Test accuracy: {accuracy}")
模型训练的代码和上篇笔记中的也几乎完全相同,只是多输出了一点调试信息。
这次我们希望能够更灵活地使用激活函数。因此,我编写了两个根据字符串获取激活函数、激活函数梯度的函数。
def get_activation_func(name):
if name == 'sigmoid':
return sigmoid
elif name == 'relu':
return relu
else:
raise KeyError(f'No such activavtion function {name}')
def get_activation_de_func(name):
if name == 'sigmoid':
return sigmoid_de
elif name == 'relu':
return relu_de
else:
raise KeyError(f'No such activavtion function {name}')
上篇笔记中,我们实现了一个单隐层的神经网络。而这周,我们要实现一个更加通用的神经网络。这个神经网络的层数、每层的神经元数、激活函数都可以修改。还是照着上次的思路,让我们看看这个子类的每个方法是怎么实现的。
class DeepNetwork(BaseRegressionModel):
def __init__(self, neuron_cnt: List[int], activation_func: List[str]):
assert len(neuron_cnt) - 1 == len(activation_func)
self.num_layer = len(neuron_cnt) - 1
self.neuron_cnt = neuron_cnt
self.activation_func = activation_func
self.W: List[np.ndarray] = []
self.b: List[np.ndarray] = []
for i in range(self.num_layer):
self.W.append(
np.random.randn(neuron_cnt[i + 1], neuron_cnt[i]) * 0.2)
self.b.append(np.zeros((neuron_cnt[i + 1], 1)))
我们要在模型初始化时确定模型的结构,因此我们需要刚刚提到的神经网络的层数、每层的神经元数、激活函数这三个信息。在初始化函数中,我们只需要传入每层神经元数的列表、激活函数名的列表即可,神经网络的层数可以通过列表长度来获取。
注意,在构建神经网络时,我们不仅要设置每一层的神经元数,还需要设置输入层的向量长度(回忆一下,输入层虽然不计入网络的层数,但我们还需要根据输入的向量长度设置参数矩阵W的形状)。因此,神经元数列表比激活函数名列表多了一个元素:
assert len(neuron_cnt) - 1 == len(activation_func)
获取了构建模型所需信息(超参数)后,我们按照公式,根据这些信息初始化模型的参数:
for i in range(self.num_layer):
self.W.append(
np.random.randn(neuron_cnt[i + 1], neuron_cnt[i]) * 0.2)
self.b.append(np.zeros((neuron_cnt[i + 1], 1)))
别忘了,我们要随机初始化W
,且令W
为一个较小的值。这里我随便取了个0.2。后面的课程会介绍这个值该怎么设置。
此外,我们还要先准备好缓存的列表:
self.Z_cache = [None] * self.num_layer
self.A_cache = [None] * (self.num_layer + 1)
self.dW_cache = [None] * self.num_layer
self.db_cache = [None] * self.num_layer
由于输入层(A0
)的信息也要保留,因此A_cache
的长度要多1。
回忆这节课提到的前向传播公式:
A [ l ] ← g [ l ] ( Z [ l ] ) = g [ l ] ( W [ l ] A [ l − 1 ] + b [ l ] ) c a c h e A [ l ] , Z [ l ] , W [ l ] , b [ l ] A^{[l]} \gets g^{[l]}(Z^{[l]})=g^{[l]}(W^{[l]}A^{[l - 1]}+b^{[l]}) \\ \ cache \ A^{[l]}, Z^{[l]},W^{[l]}, b^{[l]} A[l]←g[l](Z[l])=g[l](W[l]A[l−1]+b[l]) cache A[l],Z[l],W[l],b[l]
我们只需要按照公式做运算并缓存变量即可(下标和公式不完全对应):
def forward(self, X, train_mode=True):
if train_mode:
self.m = X.shape[1]
A = X
self.A_cache[0] = A
for i in range(self.num_layer):
Z = np.dot(self.W[i], A) + self.b[i]
A = get_activation_func(self.activation_func[i])(Z)
if train_mode:
self.Z_cache[i] = Z
self.A_cache[i + 1] = A
return A
注意,虽然公式里写了要缓存模型参数W, b
,但实际上在代码实现时,模型参数本来就是类的成员属性,不需要额外去缓存它们。
同样,按照公式翻译反向传播即可:
d A [ L ] = − y a + 1 − y 1 − a . . . d Z [ l ] ← d A [ l ] ∗ g [ l ] ′ ( Z [ l ] ) u s e Z [ l ] d W [ l ] ← 1 m d Z [ l ] A [ l − 1 ] T u s e A [ l ] d b [ l ] ← n p . m e a n ( d Z [ l ] , a x i s = 1 , k e e p d i m s = T r u e ) d A [ l − 1 ] ← W [ l ] T d Z [ l ] u s e W [ l ] . . . \begin{aligned} dA^{[L]} & = -\frac{y}{a} + \frac{1 - y}{1 - a} \\ ... & \\ dZ^{[l]} & \gets dA^{[l]} \ast g^{[l]'}(Z^{[l]}) &use \ Z^{[l]} \\ dW^{[l]} & \gets \frac{1}{m}dZ^{[l]} A^{[l-1]T} &use \ A^{[l]} \\ db^{[l]} & \gets np.mean(dZ^{[l]}, axis=1, keepdims=True) \\ dA^{[l-1]} & \gets W^{[l]T}dZ^{[l]} &use \ W^{[l]} \\ ... \end{aligned} dA[L]...dZ[l]dW[l]db[l]dA[l−1]...=−ay+1−a1−y←dA[l]∗g[l]′(Z[l])←m1dZ[l]A[l−1]T←np.mean(dZ[l],axis=1,keepdims=True)←W[l]TdZ[l]use Z[l]use A[l]use W[l]
def backward(self, Y):
dA = -Y / self.A_cache[-1] + (1 - Y) / (1 - self.A_cache[-1])
assert (self.m == Y.shape[1])
for i in range(self.num_layer - 1, -1, -1):
dZ = dA * get_activation_de_func(self.activation_func[i])(
self.Z_cache[i])
dW = np.dot(dZ, self.A_cache[i].T) / self.m
db = np.mean(dZ, axis=1, keepdims=True)
dA = np.dot(self.W[i].T, dZ)
self.dW_cache[i] = dW
self.db_cache[i] = db
这里除了第一个dA
要由误差函数的导数单独算出来外,其他的梯度直接照着公式算就可以了。由于我们把反向传播和更新参数分成了两步,这里额外缓存了dW, db
的值。
这次,梯度下降需要循环对所有参数进行:
def gradient_descent(self, learning_rate):
for i in range(self.num_layer):
self.W[i] -= learning_rate * self.dW_cache[i]
self.b[i] -= learning_rate * self.db_cache[i]
在介绍存取模型的函数之前,我们要认识两个新的numpy API。
第一个API是np.savez(filename, a_name=a, b_name=b, ...)
。它可以把ndarray
类型的数据a, b, ...
以键值对的形式记录进一个.npz
文件中,其中键值对的键是数据的名称,值是数据的值。
第二个API是np.load(filename)
。它可以从.npz
里读取出一个词典。词典中存储的键值对就是我们刚刚保存的键值对。
比如,我们可以用如下方法存取W, b
两个ndarray
:
W = np.zeros((1, 1))
b = np.zeros((1, 1))
np.savez('a.npz', W=W, b=b)
params = np.load('a.npz')
assert W == params['W']
assert b == params['b']
学会了这两个API的用法后,我们来看看该怎么存取神经网络的参数:
def save(self, filename: str):
save_dict = {}
for i in range(len(self.W)):
save_dict['W' + str(i)] = self.W[i]
for i in range(len(self.b)):
save_dict['b' + str(i)] = self.b[i]
np.savez(filename, **save_dict)
def load(self, filename: str):
params = np.load(filename)
for i in range(len(self.W)):
self.W[i] = params['W' + str(i)]
for i in range(len(self.b)):
self.b[i] = params['b' + str(i)]
和刚刚介绍的用法一样,这里我们要给神经网络中每一个参数取一个独一无二的名字,再把所有名字和值合并成键值对。保存和读取,就是对键值对的写和读。
这里我使用了**save_dict
这种传参方式。在Python中,func(**dict)
的作用是把一个词典的值当作函数的键值对参数。比如我要写func(a=a, b=b)
,我可以定义一个词典d={'a':a, 'b':b}
,再用func(**d)
把词典传入函数的键值对参数。
这次,我们还是使用和第二篇笔记中相同的猫狗数据集,作为这次任务的训练集和测试集:
def main():
train_X, train_Y, test_X, test_Y = get_cat_set(
'dldemos/LogisticRegression/data/archive/dataset', train_size=1500)
# train_X: [n_x, m]
# train_Y: [1, m]
这次我直接在get_cat_set
函数里把输入数据的形状调好了,总算不用在main函数看乱糟糟的代码了。
之后,我们根据输入的向量长度,初始化一个较深的神经网络:
n_x = train_X.shape[0]
model = DeepNetwork([n_x, 30, 30, 20, 20, 1],
['relu', 'relu', 'relu', 'relu', 'sigmoid'])
如这段代码所示,我创建了一个有4个隐藏层的神经网络,其中隐藏层的通道数分别为30, 30, 20, 20
。除了最后一层用sigmoid
以外,每一层都用relu
作为激活函数。
初始化完模型后,我们可以开始训练模型了:
model.load('work_dirs/model.npz')
train(model,
train_X,
train_Y,
500,
learning_rate=0.01,
print_interval=10,
test_X=test_X,
test_Y=test_Y)
model.save('work_dirs/model.npz')
其中,模型是否要读取和保存是可以“灵活地”注释掉的。只需要简单地调用train
函数,我们的模型就可以训起来了。
本来,写完了代码,讨论实验结果,是一件令人心情愉悦的事情:忙了大半天,总算能看一看自己的成果了。但是,在深度学习项目中,实验其实是最烦人的部分。
如第一课所述,深度学习是一个以实验为主的研究方向,深度学习项目是建立在“实验-开发”这一循环上的。一旦实验效果不佳,你就得去重新调代码。普通的编程项目,你能预计程序有怎样的输出,出了问题你能顺藤摸瓜找到bug。而对于深度学习项目,模型效果不佳,可能是代码有bug,也可能是模型不行。哪怕是知道是模型不行,你也很难确切地知道应该怎么去提升模型。因此,对于深度学习项目,开始实验,仅仅是麻烦的开始。
这不,在开启这周的小猫分类任务实验时,我是满怀期待的:上次用的逻辑回归确实太烂了。这周换了一个这么强大的模型,应该没问题了吧。
结果呢,模型跑了半天,精度还比不过逻辑回归。
我也不好去直接debug啊。我只好对着屏幕大眼瞪小眼,硬生生地用我的人脑编译器去调试代码。
我看了很久,屏幕都快被我眼睛发出的射线射穿了,我还是找不到bug。
我只好断定:这不是代码的问题,是模型或数据的问题。
调了半天超参数后,开始训练。模型用蜗牛般的速度训练了一个小时后,总算达到了 60.5% 的准确率。还好,这个模型没有太丢人,总算比之前逻辑回归的 57.5% 要高上一点点了。
可又过了半小时,这模型的准确率又只有 58.5% 了,准确度就再也上不去了。
这个结果实在是太气人了。相比逻辑回归,这么深的网络竟然只有这么小的提升。
消气后,我冷静地分析了下为什么这个“深度”神经网络的提升这么小:
还有一些其他方面的原因。现在所有运算都是在CPU上运行的,速度特别慢。如果放到GPU上运算,模型的训练会快上很多。实验速度快了,就有更多的机会去调试模型的超参数,得到更优的模型。
其实,我已经学完了后面几周的课程,知道该怎么优化神经网络的训练;我也知道该怎么在GPU上训练模型。但是,出于教学的考虑,为了让使用的知识尽可能少,我没有提前去使用一些更高级的优化方法和编程手段。我大概只发挥了一成的模型优化水平,使用GPU后实验速度保守估计提升10倍。这样算来,我现在展示的实力,最多只有真实实力的1%。一旦我稍稍拿出5成实力,这个模型的性能就会有显著提升;一旦我释放所有能量,再去多看几篇论文,使用更加优秀的分类模型,那我的模型在这个数据集上的分类精度就可以登顶全球了。只是这样太张扬了不太好。
这样一想,我也没有必要为这个模型的垃圾性能而生气。后面还有的是改进的机会。希望在后面的编程实战中,我们能一点一点见证这个分类模型的提升。
代码链接:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/DeepNetwork