神经网络的结构就不说了,网上一大堆……
这次手写数字识别采用的是 sigmoid 激活函数和 MSE 损失函数。
虽然网上说这种方式比不上 softmax 激活函数和交叉熵损失函数,后者更适合用于分类,但是实测前者可以达到 97.4% 的准确率,后者本机只能达到 92% 的准确率(固定学习率不加任何优化)。
这里就记录一下计算梯度的方式。
对于最后一层,记 d i d_i di 表示点 i i i 激活之前的值, v i = sigmoid ( d i ) = 1 1 + e − d i v_i=\text{sigmoid}(d_i)=\frac{1}{1+e^{-d_i}} vi=sigmoid(di)=1+e−di1,记期望得到的值为 g i g_i gi,那么 MSE 损失函数为 ∑ ( v j − g j ) 2 \sum (v_j-g_j)^2 ∑(vj−gj)2,对 v i v_i vi 求偏导,得到的是 2 ( v i − g i ) 2(v_i-g_i) 2(vi−gi)。
根据链式法则,我们要计算 ∂ E t o t ∂ d i \frac{\partial E_{tot}}{\partial d_i} ∂di∂Etot,则分别计算 ∂ E t o t ∂ v i , ∂ v i ∂ d i \frac{\partial E_{tot}}{\partial v_i},\frac{\partial v_i}{\partial d_i} ∂vi∂Etot,∂di∂vi
前者就是 2 ( v i − g i ) 2(v_i-g_i) 2(vi−gi),后者就是 sigmoid 的导数,算一下发现是 v i ( 1 − v i ) v_i(1-v_i) vi(1−vi)。
考虑从后往前计算,对于点 i i i 的后继点 j j j 都能求出 ∂ E t o t ∂ d j \frac{\partial E_{tot}}{\partial d_j} ∂dj∂Etot 的话,那么我们就能求出 ∂ E t o t ∂ d i \frac{\partial E_{tot}}{\partial d_i} ∂di∂Etot。
考虑先求出 ∂ E t o t ∂ v i = ∑ ∂ E t o t ∂ v j ⋅ w i , j \frac{\partial E_{tot}}{\partial v_i}=\sum \frac{\partial E_{tot}}{\partial v_j} \cdot w_{i,j} ∂vi∂Etot=∑∂vj∂Etot⋅wi,j,其中 w i , j w_{i,j} wi,j 是边权。
一般的,对于同层的点 i , j i,j i,j,我们也有 ∂ E t o t ∂ d i = ∑ ∂ E t o t ∂ v j ⋅ ∂ v j ∂ d i \frac{\partial E_{tot}}{\partial d_i}=\sum \frac{\partial E_{tot}}{\partial v_j} \cdot \frac{\partial v_j}{\partial d_i} ∂di∂Etot=∑∂vj∂Etot⋅∂di∂vj。在 sigmoid 函数的情况下,由于 i ≠ j i \neq j i=j 时 v j v_j vj 与 d i d_i di 无关,因此 ∂ E t o t ∂ d i = ∂ E t o t ∂ v i ⋅ v i ( 1 − v i ) \frac{\partial E_{tot}}{\partial d_i}=\frac{\partial E_{tot}}{\partial v_i} \cdot v_i(1-v_i) ∂di∂Etot=∂vi∂Etot⋅vi(1−vi)。于是就可以递推出每个点了。
对于边 w i , j w_{i,j} wi,j(从 i i i 连向 j j j),它需要减掉 η v i ∂ E t o t ∂ d j \eta v_i\frac{\partial E_{tot}}{\partial d_j} ηvi∂dj∂Etot( ∂ d j ∂ w i , j = v i \frac{\partial d_j}{\partial w_{i,j}}=v_i ∂wi,j∂dj=vi)。
对于 softmax 和交叉熵的组合,一定要注意同层的 i ≠ j i \neq j i=j 也是要计算的即可。下面放一下 C++ 实现的板子。
sigmoid+MSE:
mt19937 mt;
double randd() {//[-1,1)
return 2.0 * mt() / UINT_MAX - 1;
}
double sigmoid(double x) {
return 1 / (1 + exp(-x));
}
typedef vector<double> VD;
typedef vector<VD> VDD;
typedef vector<VDD> VDDD;
struct NNs {
VDDD w, dw; VDD v, b, db;
vector<int> sz;
int n; double eta;
void init(const vector<int> &s, double _eta = 0.01) {
eta = _eta;
sz = s;
n = sz.size() - 1;
v.resize(n + 1);
w.resize(n);
b.resize(n);
db.resize(n);
dw.resize(n);
for (int i = 0; i <= n; i++)
v[i].resize(sz[i]);
for (int i = 0; i < n; i++) {
w[i].resize(sz[i + 1]);
dw[i].resize(sz[i + 1]);
b[i].resize(sz[i + 1]);
db[i].resize(sz[i + 1]);
for (int j = 0; j < sz[i + 1]; j++) {
w[i][j].resize(sz[i]);
dw[i][j].resize(sz[i]);
for (int k = 0; k < sz[i]; k++)
w[i][j][k] = randd() / sz[i] * 2;
b[i][j] = randd();
}
}
}
VD calc(const VD &s) {
v[0] = s;
for (int i = 0; i < n; i++) {
for (int j = 0; j < sz[i + 1]; j++) {
double d = b[i][j];
for (int k = 0; k < sz[i]; k++)
d += w[i][j][k] * v[i][k];
v[i + 1][j] = sigmoid(d);
}
}
return v[n];
}
void back(const VD &g) {
for (int i = 0; i < sz[n]; i++)
v[n][i] = v[n][i] * (1 - v[n][i]) * 2 * (v[n][i] - g[i]);
for (int i = n; i > 0; i--) {
VD l(sz[i - 1]);
for (int j = 0; j < sz[i]; j++) {
for (int k = 0; k < sz[i - 1]; k++) {
l[k] += v[i][j] * w[i - 1][j][k];
dw[i - 1][j][k] += v[i][j] * v[i - 1][k];
}
db[i - 1][j] += v[i][j];
}
for (int j = 0; j < sz[i - 1]; j++)
v[i - 1][j] = v[i - 1][j] * (1 - v[i - 1][j]) * l[j];
}
}
void train(VDD s, VDD g, int B = 1) {
int m = (int)s.size();
for (int i = 0; i < m; i++) {
int t = mt() % (i + 1);
swap(s[i], s[t]), swap(g[i], g[t]);
}
for (int i = 0; i < m; i += B) {
for (int j = 0; j < n; j++)
for (int k = 0; k < sz[j + 1]; k++) {
db[j][k] = 0;
for (int l = 0; l < sz[j]; l++)
dw[j][k][l] = 0;
}
for (int j = i; j < i + B && j < m; j++) {
calc(s[j]);
back(g[j]);
}
double coef = eta / min(m - i, B);
for (int j = 0; j < n; j++)
for (int k = 0; k < sz[j + 1]; k++) {
b[j][k] -= db[j][k] * coef;
for (int l = 0; l < sz[j]; l++)
w[j][k][l] -= dw[j][k][l] * coef;
}
}
}
void input(FILE *f) {
fscanf(f, "%d", &n);
sz.resize(n + 1);
for (int i = 0; i <= n; i++)
fscanf(f, "%d", &sz[i]);
v.resize(n + 1);
w.resize(n);
b.resize(n);
db.resize(n);
dw.resize(n);
for (int i = 0; i <= n; i++)
v[i].resize(sz[i]);
for (int i = 0; i < n; i++) {
w[i].resize(sz[i + 1]);
dw[i].resize(sz[i + 1]);
b[i].resize(sz[i + 1]);
db[i].resize(sz[i + 1]);
for (int j = 0; j < sz[i + 1]; j++) {
w[i][j].resize(sz[i]);
dw[i][j].resize(sz[i]);
for (int k = 0; k < sz[i]; k++)
fscanf(f, "%lf", &w[i][j][k]);
}
}
for (int i = 0; i < n; i++)
for (int j = 0; j < sz[i + 1]; j++)
fscanf(f, "%lf", &b[i][j]);
}
void output(FILE *f) {
fprintf(f, "%d\n", n);
for (int i = 0; i <= n; i++)
fprintf(f, "%d%c", sz[i], " \n"[i == n]);
for (int i = 0; i < n; i++)
for (int j = 0; j < sz[i + 1]; j++)
for (int k = 0; k < sz[i]; k++)
fprintf(f, "%.12lf ", w[i][j][k]);
for (int i = 0; i < n; i++)
for (int j = 0; j < sz[i + 1]; j++)
fprintf(f, "%.12lf ", b[i][j]);
}
} nns;
int main() {
nns.init({2, 2, 2}, 0.1);//size
}
softmax 和交叉熵的只有 calc 函数和 back 函数不同(这里 int 参数直接传入类别编号):
int calc(const VD &s) {
v[0] = s;
for (int i = 0; i < n; i++) {
double a = 0, mx = 0;
for (int j = 0; j < sz[i + 1]; j++) {
double d = b[i][j];
for (int k = 0; k < sz[i]; k++)
d += w[i][j][k] * v[i][k];
v[i + 1][j] = d;
chkmax(mx, d);
}
for (int j = 0; j < sz[i + 1]; j++)
a += (v[i + 1][j] = exp(v[i + 1][j] - mx));
for (int j = 0; j < sz[i + 1]; j++)
v[i + 1][j] /= a;
}
return max_element(v[n].begin(), v[n].end()) - v[n].begin();
}
void back(int g) {
for (int i = 0; i < sz[n]; i++)
v[n][i] = i == g ? v[n][i] - 1 : v[n][i];
for (int i = n; i > 0; i--) {
VD l(sz[i - 1]);
for (int j = 0; j < sz[i]; j++) {
for (int k = 0; k < sz[i - 1]; k++) {
l[k] += v[i][j] * w[i - 1][j][k];
dw[i - 1][j][k] += v[i][j] * v[i - 1][k];
}
db[i - 1][j] += v[i][j];
}
double s = 0;
for (int j = 0; j < sz[i - 1]; j++)
s += v[i - 1][j] * l[j];
for (int j = 0; j < sz[i - 1]; j++)
v[i - 1][j] = (l[j] - s) * v[i - 1][j];
}
}
手写数字识别
上 MNIST 下载了 6w 个训练数据和 1w 个测试数据。
softmax 和交叉熵的表现很不好,不能有隐含层,如果有的话好像效果极差(知道怎么优化的教教本蒟蒻,不胜感激)
没有隐含层的话,学习率大约 0.003~0.005 左右最优,正确率大致可以达到 92% 多一点。
sigmoid 和 MSE 的表现还算不错,含有一层节点数为 64 的看起来挺优的,学习率大致 0.05,迭代 100 次可以达到约 97.4% 的正确率。(MNIST 上的数字有的我都认不得,也不能怪神经网络菜了 2333)
然后大概远古时期有个联考题,大概是给定一条数字(有噪点),要求识别出所有的数字。
数字还算挺工整的,大概如上图。只有黑白像素图的噪点还是很好去掉的,搜一下连通块大小,如果
≤
8
\le 8
≤8 就直接删掉好了。此外,这个图稍微大了些,缩成
28
×
28
28\times28
28×28 的比较好,于是删掉一些空白行,然后四宫格缩成一个格子即可。然后按照空白截断成若干个单独的数字,每个数字扩展到恰好
28
×
28
28\times28
28×28 的大小。
注意最好把所有数字都放到正中间(即上下空白行一样多,左右空白列一样多),这样可以显著提高神经网络的识别率。
然后经过上述处理之后,大概就长这样了:
还是相当工整的,比 MNIST 上的数字不知道好看多少倍(雾),所以拿着 2w 个训练数据暴力迭代 50 次,就可以对这些数字达到 100% 正确率的识别了。