500 行或更少
光学字符识别

玛丽娜·塞缪尔

简介

如果你的电脑可以洗碗、洗衣服、做饭和打扫房子,你会怎么样?我想我可以放心地说,大多数人都会很高兴得到帮助!但是,要让电脑像人类一样完成这些任务需要什么呢?

著名的计算机科学家阿兰·图灵提出了图灵测试,作为一种方法来识别机器是否具有与人类无法区分的智力。测试包括一个人向两个隐藏的实体(一个人类和一台机器)提出问题,并试图识别出哪个是哪个。如果询问者无法识别机器,则机器被认为具有与人类相当的智力。

虽然围绕图灵测试是否是对智力的有效评估,以及我们是否可以构建如此智能的机器有很多争议,但毫无疑问,已经存在着具有某种程度智力的机器。目前有软件可以帮助机器人导航办公室并执行小任务,或者帮助患有阿尔茨海默病的人。人工智能 (AI) 更常见的例子是 Google 在你搜索某些关键词时估计你正在寻找什么的方式,或者 Facebook 决定将什么内容放在你的新闻提要中的方式。

AI 的一个知名应用是光学字符识别 (OCR)。OCR 系统是一段软件,可以将手写字符的图像作为输入,并将它们解释为机器可读的文本。虽然你在将手写支票存入银行机器时可能不会多想,但后台正在进行一些有趣的工作。本章将研究一个简单 OCR 系统的工作示例,该系统使用人工神经网络 (ANN) 识别数字。但首先,让我们建立更多上下文。

什么是人工智能?

  虽然图灵对智力的定义听起来很合理,但在一天结束时,什么是智力本质上是一个哲学辩论。然而,计算机科学家已经将某些类型的系统和算法分类为 AI 的分支。每个分支用于解决某些问题集。这些分支包括以下示例,以及 许多其他示例

一般来说,ML 涉及使用大型数据集来训练系统以识别模式。训练数据集可能是标记的,这意味着为给定的输入指定了系统的预期输出,或者未标记的,这意味着未指定预期输出。使用未标记数据训练系统的算法称为*无监督*算法,而使用标记数据训练系统的算法称为*监督*算法。存在许多用于创建 OCR 系统的 ML 算法和技术,其中 ANNs 是一种方法。

人工神经网络

什么是 ANNs?

  ANN 是一个结构,由相互连接的节点组成,这些节点相互通信。该结构及其功能灵感来自生物大脑中的神经网络。赫布理论 解释了这些网络如何通过物理改变其结构和链接强度来学习识别模式。类似地,典型的 ANN(如 图 15.1 所示)在节点之间具有连接,这些连接具有权重,权重在网络学习时会更新。标记为“+1”的节点称为*偏差*。最左侧的蓝色节点列是*输入节点*,中间列包含*隐藏节点*,最右侧的列包含*输出节点*。可能有多列隐藏节点,称为*隐藏层*。

Figure 15.1 - An Artificial Neural Network

图 15.1 - 人工神经网络

图 15.1 中所有圆形节点内部的值表示节点的输出。如果我们将第 \(L\) 层中从顶部算起的第 \(n\) 个节点的输出称为 \(n(L)\),并将第 \(L\) 层中第 \(i\) 个节点与第 \(L+1\) 层中第 \(j\) 个节点之间的连接称为 \(w^{(L)}_ji\),那么节点 \(a^{(2)}_2\) 的输出为

\[ a^{(2)}_2 = f(w^{(1)}_{21}x_1 + w^{(1)}_{22}x_2 + b^{(1)}_{2}) \]

其中 \(f(.)\) 称为*激活函数*,\(b\) 称为*偏差*。激活函数是决定节点具有何种类型输出的决策者。偏差是一个具有 1 的固定输出的附加节点,可以添加到 ANN 中以提高其准确性。我们将在 设计前馈 ANN (neural_network_design.py) 中看到有关这两者的更多详细信息。

这种类型的网络拓扑称为*前馈*神经网络,因为网络中没有循环。具有节点(其输出馈送到其输入)的 ANN 称为循环神经网络。存在许多算法可用于训练前馈 ANN;一种常用的算法称为*反向传播*。我们将在本章中实现的 OCR 系统将使用反向传播。

我们如何使用 ANNs?

与大多数其他 ML 方法一样,使用反向传播的第一步是决定如何将我们的问题转换为可以由 ANN 解决的问题。换句话说,我们如何操作我们的输入数据以便可以将其馈送到 ANN?对于我们的 OCR 系统,我们可以使用给定数字的像素位置作为输入。值得注意的是,通常情况下,选择输入数据格式并不像这样简单。例如,如果我们要分析大型图像以识别其中的形状,我们可能需要预处理图像以识别其中的轮廓。这些轮廓将是输入。

确定输入数据格式后,下一步是什么?由于反向传播是一种监督算法,因此它需要使用标记数据进行训练,如 什么是人工智能? 中所述。因此,在将像素位置作为训练输入传递时,我们还必须传递相关的数字。这意味着我们必须找到或收集大量绘制数字和相关值的集合。

下一步是将数据集划分为训练集和验证集。训练数据用于运行反向传播算法以设置 ANN 的权重。验证数据用于使用训练后的网络进行预测并计算其准确性。如果我们要比较反向传播与另一种算法在我们的数据上的性能,我们将将数据分割 为 50% 用于训练,25% 用于比较两种算法的性能(验证集),最后 25% 用于测试所选算法的准确性(测试集)。由于我们没有比较算法,因此我们可以将两个 25% 的集合之一作为训练集的一部分,并将 75% 的数据用于训练网络,并将 25% 用于验证网络是否已良好训练。

识别 ANN 准确性的目的是双重的。首先,是为了避免*过度拟合*的问题。过度拟合发生在网络在预测训练集上的准确率远高于验证集上的准确率时。过度拟合告诉我们,所选训练数据无法很好地泛化,需要进行细化。其次,测试几个不同数量的隐藏层和隐藏节点的准确性有助于设计最优 ANN 大小。最优 ANN 大小将具有足够的隐藏节点和层来进行准确的预测,但也尽可能少地使用节点/连接以减少可能减慢训练和预测速度的计算开销。一旦确定了最佳大小,并且网络已完成训练,它就可以进行预测了!

简单 OCR 系统中的设计决策

  在过去的几段中,我们已经讨论了前馈 ANNs 的一些基础知识以及如何使用它们。现在是时候讨论如何构建 OCR 系统了。

首先,我们必须决定我们希望我们的系统能够做什么。为了保持简单,让我们允许用户绘制单个数字,并能够使用绘制的数字训练 OCR 系统,或者请求系统预测绘制的数字是什么。虽然 OCR 系统可以在单个机器上本地运行,但客户端-服务器设置提供了更大的灵活性。它使 ANN 的众包训练成为可能,并允许强大的服务器处理密集的计算。

我们的 OCR 系统将由 5 个主要组件组成,分为 5 个文件。将有

用户界面将很简单:一个用于在上面绘制数字的画布,以及用于训练 ANN 或请求预测的按钮。客户端将收集绘制的数字,将其转换为数组,并将其传递给服务器以进行处理,无论是作为训练样本还是作为预测请求。服务器将仅通过对 ANN 模块进行 API 调用来路由训练或预测请求。ANN 模块将在其首次初始化时使用现有数据集训练网络。然后,它会将 ANN 权重保存到文件中,并在后续启动时重新加载它们。该模块是训练和预测逻辑核心的所在地。最后,设计脚本用于试验不同的隐藏节点数量并决定什么效果最好。这些部分共同为我们提供了一个非常简化的,但功能强大的 OCR 系统。

现在我们已经考虑了系统如何在高级别上工作,是时候将这些概念转换为代码了!

一个简单的界面 (ocr.html)

如前所述,第一步是收集用于训练网络的数据。我们可以将一系列手写数字上传到服务器,但这会很麻烦。相反,我们可以让用户实际使用 HTML 画布在页面上手写数字。然后我们可以给他们几个选项,无论是训练还是测试网络,其中训练网络还包括指定绘制了哪个数字。这样,通过将人们指向网站以接收他们的输入,可以轻松地外包数据收集。以下是让我们开始的一些 HTML。

<html>
<head>
    <script src="ocr.js"></script>
    <link rel="stylesheet" type="text/css" href="ocr.css">
</head>
<body onload="ocrDemo.onLoadFunction()">
    <div id="main-container" style="text-align: center;">
        <h1>OCR Demo</h1>
        <canvas id="canvas" width="200" height="200"></canvas>
        <form name="input">
            <p>Digit: <input id="digit" type="text"> </p>
            <input type="button" value="Train" onclick="ocrDemo.train()">
            <input type="button" value="Test" onclick="ocrDemo.test()">
            <input type="button" value="Reset" onclick="ocrDemo.resetCanvas();"/>
        </form> 
    </div>
</body>
</html>

一个 OCR 客户端 (ocr.js)

由于 HTML 画布上的单个像素可能很难看到,我们可以将用于 ANN 输入的单个像素表示为一个 10x10 的真实像素的正方形。因此,真实的画布是 200x200 像素,从 ANN 的角度来看,它由 20x20 的画布表示。下面的变量将帮助我们跟踪这些测量值。

var ocrDemo = {
    CANVAS_WIDTH: 200,
    TRANSLATED_WIDTH: 20,
    PIXEL_WIDTH: 10, // TRANSLATED_WIDTH = CANVAS_WIDTH / PIXEL_WIDTH

然后,我们可以勾勒出新表示中的像素,以便更容易看到它们。这里我们有一个由 drawGrid() 生成的蓝色网格。

    drawGrid: function(ctx) {
        for (var x = this.PIXEL_WIDTH, y = this.PIXEL_WIDTH; 
                 x < this.CANVAS_WIDTH; x += this.PIXEL_WIDTH, 
                 y += this.PIXEL_WIDTH) {
            ctx.strokeStyle = this.BLUE;
            ctx.beginPath();
            ctx.moveTo(x, 0);
            ctx.lineTo(x, this.CANVAS_WIDTH);
            ctx.stroke();

            ctx.beginPath();
            ctx.moveTo(0, y);
            ctx.lineTo(this.CANVAS_WIDTH, y);
            ctx.stroke();
        }
    },

我们还需要以可以发送到服务器的形式存储绘制在网格上的数据。为简单起见,我们可以有一个名为 data 的数组,它将未着色的黑色像素标记为 0,将着色的白色像素标记为 1。我们还需要一些画布上的鼠标监听器,以便在用户绘制数字时,我们知道何时调用 fillSquare() 来将像素颜色设置为白色。这些监听器应该跟踪我们是否处于绘制状态,然后调用 fillSquare() 来进行一些简单的数学运算,并确定哪些像素需要填充。

    onMouseMove: function(e, ctx, canvas) {
        if (!canvas.isDrawing) {
            return;
        }
        this.fillSquare(ctx, 
            e.clientX - canvas.offsetLeft, e.clientY - canvas.offsetTop);
    },

    onMouseDown: function(e, ctx, canvas) {
        canvas.isDrawing = true;
        this.fillSquare(ctx, 
            e.clientX - canvas.offsetLeft, e.clientY - canvas.offsetTop);
    },

    onMouseUp: function(e) {
        canvas.isDrawing = false;
    },

    fillSquare: function(ctx, x, y) {
        var xPixel = Math.floor(x / this.PIXEL_WIDTH);
        var yPixel = Math.floor(y / this.PIXEL_WIDTH);
        this.data[((xPixel - 1)  * this.TRANSLATED_WIDTH + yPixel) - 1] = 1;

        ctx.fillStyle = '#ffffff';
        ctx.fillRect(xPixel * this.PIXEL_WIDTH, yPixel * this.PIXEL_WIDTH, 
            this.PIXEL_WIDTH, this.PIXEL_WIDTH);
    },

现在我们越来越接近有趣的部分了!我们需要一个函数来准备要发送到服务器的训练数据。这里我们有一个比较简单的 train() 函数,它对要发送的数据进行一些错误检查,将其添加到 trainArray 中,并通过调用 sendData() 将其发送出去。

    train: function() {
        var digitVal = document.getElementById("digit").value;
        if (!digitVal || this.data.indexOf(1) < 0) {
            alert("Please type and draw a digit value in order to train the network");
            return;
        }
        this.trainArray.push({"y0": this.data, "label": parseInt(digitVal)});
        this.trainingRequestCount++;

        // Time to send a training batch to the server.
        if (this.trainingRequestCount == this.BATCH_SIZE) {
            alert("Sending training data to server...");
            var json = {
                trainArray: this.trainArray,
                train: true
            };

            this.sendData(json);
            this.trainingRequestCount = 0;
            this.trainArray = [];
        }
    },

这里值得注意的一个有趣的设计是使用 trainingRequestCounttrainArrayBATCH_SIZE。这里发生的是,BATCH_SIZE 是一个预定义的常量,表示客户端在向服务器发送批处理请求以供 OCR 处理之前将跟踪多少训练数据。批处理请求的主要原因是避免向服务器发送太多请求而使服务器不堪重负。如果存在许多客户端(例如,许多用户在 ocr.html 页面上训练系统),或者如果客户端中存在另一层用于获取扫描的绘制数字并将它们转换为像素以训练网络,则 BATCH_SIZE 为 1 将会导致许多不必要的请求。这种方法很好,因为它给了客户端更多灵活性,但是,在实践中,批处理也应该在需要时在服务器上进行。可能会发生拒绝服务 (DoS) 攻击,其中恶意客户端故意向服务器发送许多请求以使服务器不堪重负,从而导致服务器崩溃。

我们还需要一个 test() 函数。与 train() 类似,它应该对数据的有效性进行简单的检查,并将数据发送出去。但是,对于 test(),不会进行批处理,因为用户应该能够请求预测并获得立即结果。

    test: function() {
        if (this.data.indexOf(1) < 0) {
            alert("Please draw a digit in order to test the network");
            return;
        }
        var json = {
            image: this.data,
            predict: true
        };
        this.sendData(json);
    },

最后,我们需要一些函数来执行 HTTP POST 请求、接收响应以及处理沿途的任何潜在错误。

    receiveResponse: function(xmlHttp) {
        if (xmlHttp.status != 200) {
            alert("Server returned status " + xmlHttp.status);
            return;
        }
        var responseJSON = JSON.parse(xmlHttp.responseText);
        if (xmlHttp.responseText && responseJSON.type == "test") {
            alert("The neural network predicts you wrote a \'" 
                   + responseJSON.result + '\'');
        }
    },

    onError: function(e) {
        alert("Error occurred while connecting to server: " + e.target.statusText);
    },

    sendData: function(json) {
        var xmlHttp = new XMLHttpRequest();
        xmlHttp.open('POST', this.HOST + ":" + this.PORT, false);
        xmlHttp.onload = function() { this.receiveResponse(xmlHttp); }.bind(this);
        xmlHttp.onerror = function() { this.onError(xmlHttp) }.bind(this);
        var msg = JSON.stringify(json);
        xmlHttp.setRequestHeader('Content-length', msg.length);
        xmlHttp.setRequestHeader("Connection", "close");
        xmlHttp.send(msg);
    }

服务器 (server.py)

尽管这是一个简单的服务器,只是中继信息,但我们仍然需要考虑如何接收和处理 HTTP 请求。首先,我们需要决定使用哪种 HTTP 请求。在上一节中,客户端使用 POST,但我们为什么要决定这样做?由于数据正在发送到服务器,因此 PUT 或 POST 请求最有意义。我们只需要发送一个 json 主体,而不需要任何 URL 参数。因此,从理论上讲,GET 请求也可以工作,但从语义上讲没有意义。然而,PUT 和 POST 之间的选择是程序员之间一个长期存在的争论;KNPLabs 以幽默的方式总结了这些问题

另一个需要考虑的问题是,是否要将 "train" 和 "predict" 请求发送到不同的端点(例如,https://127.0.0.1/trainhttps://127.0.0.1/predict)或同一个端点,然后分别处理数据。在这种情况下,我们可以采用后一种方法,因为在每种情况下对数据执行的操作之间的差异很小,足以放入一个简短的 if 语句中。在实践中,如果服务器要对每种请求类型进行更详细的处理,最好将它们作为单独的端点。这个决定反过来影响了使用哪些服务器错误代码。例如,当有效负载中未指定 "train" 或 "predict" 时,会发送 400 "Bad Request" 错误。如果使用单独的端点,则不会出现这个问题。OCR 系统后台执行的处理可能会因任何原因失败,如果在服务器中未正确处理,则会发送 500 "Internal Server Error"。同样,如果将端点分开,将有更多空间进行详细说明,以便发送更合适的错误。例如,识别内部服务器错误实际上是由错误的请求引起的。

最后,我们需要决定何时何地初始化 OCR 系统。一种好方法是在 server.py 中初始化它,但在服务器启动之前。这是因为在第一次运行时,OCR 系统需要在启动时使用一些预先存在的数据来训练网络,这可能需要几分钟。如果服务器在该处理完成之前启动,则任何训练或预测请求都会抛出异常,因为 OCR 对象尚未初始化,这取决于当前实现。另一种可能的实现可以创建一个不精确的初始 ANN,用于前几个查询,而新的 ANN 则异步地在后台进行训练。这种替代方法确实允许立即使用 ANN,但实现更复杂,并且只有在服务器重置时才能节省服务器启动时间。这种类型的实现对于需要高可用性的 OCR 服务更有利。

这里我们有一个简短函数中的大部分服务器代码,该函数处理 POST 请求。

    def do_POST(s):
        response_code = 200
        response = ""
        var_len = int(s.headers.get('Content-Length'))
        content = s.rfile.read(var_len);
        payload = json.loads(content);

        if payload.get('train'):
            nn.train(payload['trainArray'])
            nn.save()
        elif payload.get('predict'):
            try:
                response = {
                    "type":"test", 
                    "result":nn.predict(str(payload['image']))
                }
            except:
                response_code = 500
        else:
            response_code = 400

        s.send_response(response_code)
        s.send_header("Content-type", "application/json")
        s.send_header("Access-Control-Allow-Origin", "*")
        s.end_headers()
        if response:
            s.wfile.write(json.dumps(response))
        return

设计前馈 ANN (neural_network_design.py)

  在设计前馈 ANN 时,我们必须考虑一些因素。第一个是使用什么激活函数。我们之前提到了激活函数,它是节点输出的决策者。激活函数做出决策的类型将帮助我们决定使用哪个激活函数。在本例中,我们将设计一个 ANN,它为每个数字(0-9)输出一个介于 0 和 1 之间的值。更接近 1 的值意味着 ANN 预测这是绘制的数字,而更接近 0 的值意味着预测它不是绘制的数字。因此,我们希望激活函数的输出要么接近 0,要么接近 1。我们还需要一个可微分的函数,因为我们需要在反向传播计算中使用导数。在这种情况下,常用函数是 sigmoid 函数,因为它满足这两个约束条件。StatSoft 提供了 常见激活函数及其属性的列表

第二个需要考虑的因素是,我们是否要包含偏差。我们之前已经多次提到偏差,但实际上没有谈论它们是什么,以及为什么使用它们。让我们尝试通过回到 图 15.1 中节点输出如何计算来理解这一点。假设我们有一个输入节点和一个输出节点,我们的输出公式将是 \(y = f(wx)\),其中 \(y\) 是输出,\(f()\) 是激活函数,\(w\) 是节点之间连接的权重,\(x\) 是节点的变量输入。偏差本质上是一个节点,其输出始终为 \(1\)。这将改变输出公式为 \(y = f(wx + b)\),其中 \(b\) 是偏差节点和下一个节点之间连接的权重。如果我们将 \(w\)\(b\) 视为常量,将 \(x\) 视为变量,那么添加偏差会在我们对 \(f(.)\) 的线性函数输入中添加一个常量。

因此,添加偏差允许 \(y\) 轴截距发生变化,并且通常会为节点的输出提供更大的灵活性。通常最好包含偏差,尤其是对于输入和输出数量较少的 ANN。偏差允许 ANN 的输出有更大的灵活性,从而为 ANN 提供更大的精度空间。如果没有偏差,我们不太可能使用我们的 ANN 做出正确的预测,或者需要更多隐藏节点才能做出更准确的预测。

其他需要考虑的因素是隐藏层的数量和每层隐藏节点的数量。对于具有许多输入和输出的较大的 ANN,这些数字是通过尝试不同的值并测试网络的性能来决定的。在本例中,性能是通过训练给定大小的 ANN 并查看验证集中正确分类的百分比来衡量的。在大多数情况下,单个隐藏层足以实现良好的性能,因此我们只在这里试验隐藏节点的数量。

# Try various number of hidden nodes and see what performs best
for i in xrange(5, 50, 5):
    nn = OCRNeuralNetwork(i, data_matrix, data_labels, train_indices, False)
    performance = str(test(data_matrix, data_labels, test_indices, nn))
    print "{i} Hidden Nodes: {val}".format(i=i, val=performance)

这里,我们初始化一个 ANN,其隐藏节点数量在 5 到 50 之间,以 5 为增量。然后,我们调用 test() 函数。

def test(data_matrix, data_labels, test_indices, nn):
    avg_sum = 0
    for j in xrange(100):
        correct_guess_count = 0
        for i in test_indices:
            test = data_matrix[i]
            prediction = nn.predict(test)
            if data_labels[i] == prediction:
                correct_guess_count += 1

        avg_sum += (correct_guess_count / float(len(test_indices)))
    return avg_sum / 100

内部循环计算正确分类的数量,然后在最后除以尝试分类的数量。这将给出 ANN 的比率或百分比精度。由于每次训练 ANN 时,其权重可能略有不同,因此我们在外部循环中重复此过程 100 次,以便我们可以计算该特定 ANN 配置精度的平均值。在本例中,neural_network_design.py 的样本运行如下所示

PERFORMANCE
-----------
5 Hidden Nodes: 0.7792
10 Hidden Nodes: 0.8704
15 Hidden Nodes: 0.8808
20 Hidden Nodes: 0.8864
25 Hidden Nodes: 0.8808
30 Hidden Nodes: 0.888
35 Hidden Nodes: 0.8904
40 Hidden Nodes: 0.8896
45 Hidden Nodes: 0.8928

从这个输出中,我们可以得出结论,15 个隐藏节点是最优的。从 10 个节点增加到 15 个节点,精度提高了约 1%,而要将精度再提高 1%,则需要再增加 20 个节点。增加隐藏节点的数量还会增加计算开销。因此,具有更多隐藏节点的网络需要更长的时间进行训练和进行预测。因此,我们选择使用最后一个导致精度显著提高的隐藏节点数量。当然,在设计 ANN 时,计算开销可能不是问题,而最重要的是要获得最精确的 ANN。在这种情况下,最好选择 45 个隐藏节点,而不是 15 个。

核心 OCR 功能

在本节中,我们将讨论如何通过反向传播进行实际训练,如何使用网络进行预测,以及核心功能的其他关键设计决策。

通过反向传播进行训练 (ocr.py)

我们使用反向传播算法来训练我们的 ANN。它包含 4 个主要步骤,这些步骤会对训练集中的每个样本重复执行,每次都会更新 ANN 权重。

首先,我们将权重初始化为小的(介于 -1 和 1 之间)随机值。在本例中,我们将它们初始化为介于 -0.06 和 0.06 之间的随机值,并将其存储在矩阵 theta1theta2input_layer_biashidden_layer_bias 中。由于每一层中的每个节点都连接到下一层中的每个节点,因此我们可以创建一个具有 m 行和 n 列的矩阵,其中 n 是一层中的节点数量,m 是相邻层中的节点数量。这个矩阵将表示这两层之间连接的所有权重。这里 theta1 具有 400 列,用于我们的 20x20 像素输入,以及 num_hidden_nodes 行。类似地,theta2 表示隐藏层和输出层之间的连接。它具有 num_hidden_nodes 列和 NUM_DIGITS (10) 行。另外两个向量 (1 行),input_layer_biashidden_layer_bias 表示偏差。

    def _rand_initialize_weights(self, size_in, size_out):
        return [((x * 0.12) - 0.06) for x in np.random.rand(size_out, size_in)]
            self.theta1 = self._rand_initialize_weights(400, num_hidden_nodes)
            self.theta2 = self._rand_initialize_weights(num_hidden_nodes, 10)
            self.input_layer_bias = self._rand_initialize_weights(1, 
                                                                  num_hidden_nodes)
            self.hidden_layer_bias = self._rand_initialize_weights(1, 10)

第二步是**前向传播**,本质上是从输入节点开始,逐层计算节点输出,如什么是人工神经网络 (ANN)?中所述。在这里,y0 是一个大小为 400 的数组,包含我们想要用来训练 ANN 的输入。我们将 theta1y0 的转置相乘,这样我们就得到了两个大小为 (num_hidden_nodes x 400) * (400 x 1) 的矩阵,并得到一个大小为 num_hidden_nodes 的隐藏层输出向量。然后,我们将偏差向量加到这个输出向量上,并对该输出向量应用向量化的 sigmoid 激活函数,得到 y1y1 是我们的隐藏层的输出向量。相同过程再次重复以计算输出节点的 y2。现在,y2 是我们的输出层向量,其值表示其索引是所绘制数字的可能性。例如,如果有人绘制了数字 8,如果 ANN 做出了正确的预测,则 y2 在第 8 个索引处的值将是最大的。然而,由于 6 看起来更像 8 并且更有可能使用与 8 相同的像素来绘制,因此 6 可能比 1 有更高的可能性是所绘制的数字。随着 ANN 训练的每个额外绘制的数字,y2 将变得更加准确。

    # The sigmoid activation function. Operates on scalars.
    def _sigmoid_scalar(self, z):
        return 1 / (1 + math.e ** -z)
            y1 = np.dot(np.mat(self.theta1), np.mat(data['y0']).T)
            sum1 =  y1 + np.mat(self.input_layer_bias) # Add the bias
            y1 = self.sigmoid(sum1)

            y2 = np.dot(np.array(self.theta2), y1)
            y2 = np.add(y2, self.hidden_layer_bias) # Add the bias
            y2 = self.sigmoid(y2)

第三步是**反向传播**,它涉及计算输出节点的误差,然后计算每个中间层的误差,直至返回到输入。在这里,我们首先创建一个期望输出向量 actual_vals,在代表所绘制数字值的数字索引处为 1,其他位置为 0。输出节点的误差向量 output_errors 通过从 actual_vals 中减去实际输出向量 y2 来计算。对于之后的每个隐藏层,我们计算两个部分。首先,我们将下一层的转置权重矩阵乘以其输出误差。然后,我们将激活函数的导数应用于前一层。然后,我们对这两个部分进行元素级乘法,得到一个隐藏层的误差向量。在这里,我们将其称为 hidden_errors

            actual_vals = [0] * 10 
            actual_vals[data['label']] = 1
            output_errors = np.mat(actual_vals).T - np.mat(y2)
            hidden_errors = np.multiply(np.dot(np.mat(self.theta2).T, output_errors), 
                                        self.sigmoid_prime(sum1))

权重更新根据前面计算的误差调整 ANN 的权重。权重在每一层通过矩阵乘法更新。每一层的误差矩阵乘以前一层的输出矩阵。该乘积然后乘以一个称为学习率的标量并加到权重矩阵上。学习率是一个介于 0 和 1 之间的值,它影响 ANN 中学习的速度和准确性。较大的学习率值将产生一个学习速度快但准确性较低的 ANN,而较小的学习率值将产生一个学习速度慢但准确性更高的 ANN。在我们的案例中,学习率的值相对较小,为 0.1。这很有效,因为我们不需要 ANN 立即训练,以便用户可以继续进行训练或预测请求。偏差通过简单地将学习率乘以该层的误差向量来更新。

            self.theta1 += self.LEARNING_RATE * np.dot(np.mat(hidden_errors), 
                                                       np.mat(data['y0']))
            self.theta2 += self.LEARNING_RATE * np.dot(np.mat(output_errors), 
                                                       np.mat(y1).T)
            self.hidden_layer_bias += self.LEARNING_RATE * output_errors
            self.input_layer_bias += self.LEARNING_RATE * hidden_errors

测试训练后的网络 (ocr.py)

一旦 ANN 通过反向传播训练完成,使用它进行预测就相当简单了。正如我们在这里看到的,我们首先计算 ANN 的输出 y2,这与反向传播步骤 2 中所做的一样。然后,我们查找向量中具有最大值的索引。该索引是 ANN 预测的数字。

    def predict(self, test):
        y1 = np.dot(np.mat(self.theta1), np.mat(test).T)
        y1 =  y1 + np.mat(self.input_layer_bias) # Add the bias
        y1 = self.sigmoid(y1)

        y2 = np.dot(np.array(self.theta2), y1)
        y2 = np.add(y2, self.hidden_layer_bias) # Add the bias
        y2 = self.sigmoid(y2)

        results = y2.T.tolist()[0]
        return results.index(max(results))

其他设计决策 (ocr.py)

网上有很多资源更详细地介绍了反向传播的实现。一个很好的资源来自维拉米特大学的一门课程。它介绍了反向传播的步骤,然后解释了如何将其转换为矩阵形式。虽然使用矩阵进行计算的量与使用循环相同,但好处是代码更简单,更易于阅读,并且嵌套循环更少。正如我们所看到的,整个训练过程使用矩阵代数在不到 25 行代码内编写。

正如简单 OCR 系统的设计决策的介绍中提到的,持久化 ANN 的权重意味着当服务器关闭或因任何原因突然关闭时,我们不会丢失在训练它时取得的进展。我们通过将权重以 JSON 格式写入文件来持久化它们。启动时,OCR 将 ANN 的保存权重加载到内存中。保存函数不是由 OCR 内部调用,而是由服务器决定何时执行保存。在我们的案例中,服务器在每次更新后保存权重。这是一个快速简单的解决方案,但它不是最优的,因为写入磁盘非常耗时。这也阻止我们处理多个并发请求,因为没有机制可以防止同时写入同一个文件。在更复杂的服务器中,保存可能在关闭时完成,或者每隔几分钟完成一次,并使用某种形式的锁定或时间戳协议来确保数据不丢失。

    def save(self):
        if not self._use_file:
            return

        json_neural_network = {
            "theta1":[np_mat.tolist()[0] for np_mat in self.theta1],
            "theta2":[np_mat.tolist()[0] for np_mat in self.theta2],
            "b1":self.input_layer_bias[0].tolist()[0],
            "b2":self.hidden_layer_bias[0].tolist()[0]
        };
        with open(OCRNeuralNetwork.NN_FILE_PATH,'w') as nnFile:
            json.dump(json_neural_network, nnFile)

    def _load(self):
        if not self._use_file:
            return

        with open(OCRNeuralNetwork.NN_FILE_PATH) as nnFile:
            nn = json.load(nnFile)
        self.theta1 = [np.array(li) for li in nn['theta1']]
        self.theta2 = [np.array(li) for li in nn['theta2']]
        self.input_layer_bias = [np.array(nn['b1'][0])]
        self.hidden_layer_bias = [np.array(nn['b2'][0])]

结论

现在我们已经了解了 AI、ANN、反向传播和构建端到端的 OCR 系统,让我们回顾一下本章的重点和总体情况。

我们从本章开始,介绍了 AI、ANN 以及我们将要实现的内容。我们讨论了什么是 AI 以及 AI 的使用示例。我们看到,AI 本质上是一组算法或问题解决方法,可以像人类一样为问题提供答案。然后,我们研究了前馈 ANN 的结构。我们了解到,计算给定节点的输出就像简单地将前一节点的输出与其连接权重相乘的乘积之和一样简单。我们讨论了如何通过首先格式化输入并将数据划分为训练集和验证集来使用 ANN。

在掌握了一些背景知识后,我们开始讨论如何创建一个基于 Web 的客户端-服务器系统来处理用户的训练或测试 OCR 请求。然后,我们讨论了客户端如何将绘制的像素解释为数组,并向 OCR 服务器执行 HTTP 请求以执行训练或测试。我们讨论了我们的简单服务器如何读取请求以及如何通过测试几个隐藏节点数的性能来设计 ANN。最后,我们介绍了反向传播的核心训练和测试代码。

虽然我们构建了一个看似功能齐全的 OCR 系统,但本章只是触及了真实 OCR 系统工作方式的皮毛。更复杂的 OCR 系统可能具有预处理的输入,使用混合 ML 算法,具有更广泛的设计阶段或其他进一步优化。