Web编程技术营地
研究、演示、创新

神经网络

撰写时间:2025-10-25

修订时间:2025-11-07

AI学习的本质

AI学习的本质是,对于一个输入源,先进行猜测(计算),得出结果。接着与期待值比较,根据差距来不断调节猜测的算法(训练),最终实现精准的输出结果。

第一个步骤如下图所示:

digraph { graph [ rankdir=LR; ] node [ shape=plaintext; style="solid"; ] Calc [ shape="circle" style="dashed" label="AI Calc" ]; input -> Calc -> output; }

下面以公里转换为英里为例。

我们知道,公里乘以一个常量,将得到一个英里。要得出常量,用英里除以公里即可。但作为AI,它并不知道此算法。它只知道:

  1. 研究人员已经通过精准测量而得出的具体公里数值及其对应的英里数值。
    例如,当公里数为100时,所测量的英里数为62.137
  2. 公里与英里是一种线性的对应关系。
    因此,AI计算的粗略算法为: digraph { graph [ rankdir=LR; ] node [ style="filled, rounded", shape=box ] KiloMetres -> Miles [label="× C"]; }

现在,我们将训练AI,让其得出正常的常量数值。当前的状态是:

digraph { graph [ rankdir=LR; ] node [ shape=plaintext; style="solid"; ] KiloMetres [ label=<
KiloMetres
100
> ]; Calc [ shape="circle", style="dashed", label="KM * C (?)" ]; Miles [ label=<
Miles
?
> ]; ExpectedMiles [ label=<
ExpectedMiles
62.137
> ]; KiloMetres -> Calc -> Miles; ExpectedMiles; }

先令常量值为0.5试一下。

let kiloMetres = 100; let expectedMiles = 62.137; pc.log('kiloMetres = %d, expectedMiles = %f', kiloMetres, expectedMiles); const C = 0.5; let miles = kiloMetres * C; pc.log('%s', '\n'); pc.log('C = %f', C); let errors = miles - expectedMiles; pc.log('miles = %f, errors = %f', miles, errors);

示意图:

digraph { graph [ rankdir=LR; ] node [ shape=plaintext; fillcolor=invis; ] Kilometres [label=<
Kilometres
100
>] Constant [label=<
C
0.5
>, shape=circle, style=dashed] Miles [label=<
Miles
50
>] ExpectedMiles [label=<
ExpectedMiles
62.137
>] Errors [label=<
Errors
-12.137
>] Kilometres -> Constant -> Miles; { rank=same; Miles -> ExpectedMiles [label="sub", fontcolor=gray, color=gray, style=dashed]; ExpectedMiles -> Errors [fontcolor=gray, color=gray, style=dashed]; } Errors -> Constant [constraint=false, label="feedback", color=gray, fontcolor=gray, style=dashed]; }

C的值为0.5时,结果比期待值12.137,因此下一步,我们应适当地增大C的数值。

上面,我们根据有符号的变量errors的值,来调节C的值。如果errors的值为负数,则需增大C的值;如果errors的值为正数,则需减小C的值;如果正好等于0,则C的值就确定下来了。

因此,将C的值增大为0.6试一下。此为第二个步骤。

const C = 0.6; let miles = kiloMetres * C; let errors = miles - expectedMiles; pc.log('C = %f', C); pc.log('miles = %f, expected = %f, errors = %f', miles, expectedMiles, errors.toFixed(3));

C的值为0.6时,结果比期待值2.137。相比于之前的12.137,这个结果,误差更小,更接近于期待值。

errors的值仍为负数,因此仍需继续增大C的值。将其调整为0.7看看。

const C = 0.7; let miles = kiloMetres * C; let errors = miles - expectedMiles; pc.log('C = %f', C); pc.log('miles = %f, expected = %f, errors = %f', miles, expectedMiles, errors.toFixed(3));

C的值为0.7时,误差值由负转正,说明C的值调整得过大了。

实际上,误差值由负变正,并不能说明与上次结果相比、C的值这次就更不好了。结果好不好,我们应考量误差值的绝对值,我们称之为差距,差距越接近于0,则说明结果越好。

const C = 0.7; let miles = kiloMetres * C; let errors = miles - expectedMiles; let dist = Math.abs(errors); pc.log('C = %f', C); pc.log('miles = %f, expected = %f, errors = %f, dist = %f', miles, expectedMiles, errors.toFixed(3), dist.toFixed(3));

这次的结果,差距为7.863,上次的结果,差距为2.137,说明当C的值从0.6调整到0.7后,差距越来越大了。

C的值为0.6时,errors的值为-2.137,我们需增大C的值;而当我们将其值加上0.1而变为0.7时,差距却越来越大。

问题出在哪里?

很简单,飞机在空中飞行时,可以自由加速。但它临近降落时,若要精准地降落到机场跑道上,它必须减速,否则就会冲出跑道外。

同理,当errors的值为-2.137时,说明我们离机场还有使用变量dist来表示的2.137个单位的距离;而当errors的值为7.863,说明飞机已经冲出跑道外7.863个单位的距离。也即,在C的值从0.6增大到0.7这个阶段时,我们需要减速了。减速的方法,是将原来的增幅0.1适当减小。

现在,将增幅从原来的0.1调整为0.03,则C的值从0.6变为0.63

const C = 0.63; let miles = kiloMetres * C; let errors = miles - expectedMiles; let dist = Math.abs(errors); pc.log('C = %f', C); pc.log('miles = %f, expected = %f, errors = %f, dist = %f', miles, expectedMiles, errors.toFixed(3), dist.toFixed(3));

对比三次的结果:

CErrorsdist
0.6-2.1372.137
0.77.8637.863
0.630.8630.863

取最小差距值0.863,则在容差值小于等于1.0的前提下,我们可以接受C值为0.63,从而终止AI的训练了。

C的值标准答案是多少?62.137 / 100 = 0.621。可见,通过训练,AI已经很接近目标了。

上面的步骤可总结如下:

  1. 为常量随意设定一个初始值,由此计算出差距值。如果差距值不为0,则需不断调整常量值。
  2. 如果误差为负,则需增大常量值;如果误差为正,则需减小常量值。
  3. 需不断减少差距。
  4. 当结果达到容差值时,停止训练。

下面是一个可连续自动训练,并在指定的容差值域内,自动终止训练的版本。

let kiloMetres = 100; let expectedMiles = 62.137; const TOLERANCE = 0.5; let value = 0.5; train(value); function getErrors(constValue) { return kiloMetres * constValue - expectedMiles; } function train(constValue) { let prevDist = Infinity; let errors = getErrors(constValue); let dist = Math.abs(errors); let step = 0.1; let index = 1; while (dist > TOLERANCE) { pc.log('%d: %f, %f, %f', index, constValue.toFixed(5), errors.toFixed(3), dist.toFixed(3)); if (errors < 0) { constValue = constValue + step; } else { constValue = constValue - step; } errors = getErrors(constValue); dist = Math.abs(errors); if (dist > prevDist) { step /= 2; } prevDist = dist; index++; } pc.log('%d: %f, %f, %f', index, constValue.toFixed(5), errors.toFixed(3), dist.toFixed(3)); }

分类器

瓢虫与毛虫分类器

设有以下两种昆虫各有一个:

实例宽度长度类型
13.01.0瓢虫
21.03.0毛虫

瓢虫圆短,毛虫细长。若以X轴代表宽度,Y轴代表长度,则可在坐标系上画一斜线将它们区分开来。这条斜线称之为分类器

drawWorm(3, 1, '瓢虫'); drawWorm(1, 3, '毛虫'); let slope = 0.55; drawLineBySlope(slope);

我们的目标是,如何通过训练,得到一条较为合适的斜率。

训练第一个样本

先取第一个实例(3.0, 1.0)的数据。随便取一个斜率值为0.85,先绘制一条斜线。

let wormX = 3.0; let wormY = 1.0; drawWorm(wormX, wormY, '瓢虫'); let x = wormX; let expectedY = wormY + 0.2; drawLineByExpectedValue(x, expectedY); let slope = 0.85; drawLineBySlope(slope); let y = slope * x; let errors = y - expectedY; pc.log(`x = %f, y = %f, expectedY = %f, errors = %f`, x.toFixed(3), y.toFixed(3), expectedY.toFixed(3), errors.toFixed(3)); drawErrorClue(`y = ${slope}x`, Point(x, expectedY));

绿色的虚线为期待的正确分界线。因为是分界线,因此当瓢虫的y值为1.0时,我们加上一点偏移值0.2,让此分界线画在瓢虫的上方。

上图说明,当斜率取0.85时,在所绘制出的分界线上面,当x值为3.0y值为2.55,比正确的y1.2多出了1.35。同时可以看出,灰线应围绕原点顺时针旋转以紫色所标示的角度后,才能得出正确的分界线。

现在,如何根据这个errors值来调节我们原来随机设定的斜率slope0.85?

两条直线均有斜率。下面求出它们的差值。

let x = 3.0; let y = 2.55; let expectedY = 1.2; let slope1 = y / x; let slope2 = expectedY / x; pc.log(slope1, slope2); let deltaSlope = slope1 - slope2; pc.log(`∆Slope = %f`, deltaSlope);

也即说,如果使用原来的斜率0.85减去这个斜率偏移值0.45,将得到正确的分界线斜率。

接下来,这个斜率偏移值与errors值有何隐藏的关系?根据上面slope1slope2的公式,我们试着能否推导出这种关系来。

let deltaSlope = slope1 - slope2 = y / x - expectedY / x = (y - expectedY) / x = errors / x;

我们得到了一个很重要的公式,即,errors值除以x的值,可直接得出斜率偏移值。

`deltaSlope = "errors" / x`

最后,根据deltaSlope来修正slope的值:

`"targetSlope" = slope - deltaSlope`

现在可以收尾了。

let wormX = 3.0; let wormY = 1.0; drawWorm(wormX, wormY, '瓢虫'); let x = wormX; let expectedY = wormY + 0.2; drawLineByExpectedValue(x, expectedY); let slope = 0.85; drawLineBySlope(slope); let y = slope * x; let errors = y - expectedY; let deltaSlope = errors / x; let targetSlope = slope - deltaSlope; pc.log(`x = %f, y = %f, expectedY = %f, errors = %f, deltaSlope = %f, targetSlope = %f`, x.toFixed(3), y.toFixed(3), expectedY.toFixed(3), errors.toFixed(3), deltaSlope.toFixed(3), targetSlope.toFixed(3)); drawErrorClue(`y = ${slope}x`, Point(x, expectedY)); drawLineBySlope(targetSlope);

先以随机的斜率0.85绘制分界线,算出误差值后,根据此误差值求出斜率偏移值。再根据斜率偏移值调整原来的随机斜率。最后,根据经调整的斜率重新绘制一条分界线。从上图可见,经调整后的分界线与所期待的分界线完全重合。

下面渲染斜率变化的动画过程。

const { AnimationFrameUtils: AFUtils } = await import('/js/esm/AnimationFrameUtils.js'); let wormX = 3.0; let wormY = 1.0; let slope = 0.85; let currSlope = slope; const SPEED = 0.001; let x = wormX; let expectedY = wormY + 0.2; let y = slope * x; let errors = y - expectedY; let deltaSlope = errors / x; let targetSlope = slope - deltaSlope; function animate(frameIndex, timeStamp, timeElapsed, stopAnimation) { clearCanvas(); setupShortAxis(4, 4); drawWorm(wormX, wormY, '瓢虫'); drawLineByExpectedValue(x, expectedY); drawLineBySlope(currSlope); pc.logStatic('curr-state', `slope = %f, currSlope = %f, targetSlope = %f, deltaSlope = %f`, slope.toFixed(2), currSlope.toFixed(2), targetSlope.toFixed(2), deltaSlope.toFixed(2)); if ((slope > targetSlope && currSlope > targetSlope) || (slope < targetSlope && currSlope < targetSlope)) { currSlope -= deltaSlope * SPEED; } else { stopAnimation(); pc.log('%s', 'Animation stops.'); } } AFUtils.InitAnimationFrames(10, animate);

根据计算出来的deltaSlope设置一个动画速度,将其在每帧中更新的量向currSlope赋值,然后根据currSlope渲染动画。当currSlope的值达到targetSlope的值时,终止动画。

训练第二个样本

先单独渲染一只毛虫。

const { AnimationFrameUtils: AFUtils } = await import('/js/esm/AnimationFrameUtils.js'); let wormX = 1.0; let wormY = 3.0; let wormType = '毛虫'; const MC_EXPECTED_OFFSET = -0.6; let slope = 0.85; let currSlope = slope; const SPEED = 0.001; let x = wormX; let expectedY = wormY + MC_EXPECTED_OFFSET; let y = slope * x; let errors = y - expectedY; let deltaSlope = errors / x; let targetSlope = slope - deltaSlope; let button; render(); appendButton(); function appendButton() { button = document.createElement('button'); button.textContent = 'Start Animation'; button.style.margin = '0.5em'; button.style.padding = '0.8em'; button.addEventListener('click', (evt) => { currSlope = slope; const SKIP_FRAMES_COUNT = 10; AFUtils.InitAnimationFrames(SKIP_FRAMES_COUNT, animate); button.textContent = 'Restart Animation'; }); pc.appendChild(button); } function render() { setupShortAxis(4, 4); drawWorm(wormX, wormY, wormType); drawLineByExpectedValue(x, expectedY); drawLineBySlope(currSlope); pc.logStatic('curr-state', `slope = %f, currSlope = %f, targetSlope = %f, deltaSlope = %f`, slope.toFixed(2), currSlope.toFixed(2), targetSlope.toFixed(2), deltaSlope.toFixed(2)); } function animate(frameIndex, timeStamp, timeElapsed, stopAnimation) { clearCanvas(); render(); if ((slope > targetSlope && currSlope > targetSlope) || (slope < targetSlope && currSlope < targetSlope)) { currSlope -= deltaSlope * SPEED; } else { stopAnimation(); button.textContent = 'Start Animation'; } }

对于毛虫,因其细长,故Y轴的偏移值设为-0.6,以让分界线绘制在毛虫的下方。

参考资源

书籍

  1. Tariq, Rashid. Python神经网络编程 [M]. 北京:人民邮电出版社,2018