1. 程式人生 > >AI應用開發實戰

AI應用開發實戰

擴充套件手寫數字識別應用

識別並計算簡單手寫數學表示式

主要知識點

  • 瞭解MNIST資料集
  • 瞭解如何擴充套件資料集
  • 實現手寫算式計算器

簡介

本文將介紹一例支援識別手寫數學表示式並對其進行計算的人工智慧應用的開發案例。本文的應用是基於前文“手寫識別應用入門”中的基礎應用進行擴充套件實現的。本文將通過這一案例,展示基本的資料整理和擴充套件人工智慧模型的過程,以及介紹如何利用手寫輸入的特性來簡化字元分割的過程。並且本文將演示如何利用Visual Studio Tools for AI進行批量推理,以便利用底層人工智慧框架的平行計算,實現推理加速。此外,本文還將對該應用的主要程式碼邏輯進行分析、講解。

背景

“手寫識別應用入門”中,我們介紹了能識別單個手寫字母、基於MNIST資料集的人工智慧應用,並且在我們的幾次試驗中,該應用表現良好,能比較準確地將手寫的數字圖形識別成對應的數字。那麼,該應用能不能識別更多種類的手寫字元,甚至是同時的出現多個字元呢?這樣的情形有很多,比如生活中常見的數學表示式(形如1+2x3)。這樣的複合情形更為常見,也更具現實意義。相比之下,如果一次識別僅能一個手寫數字,應用就會有比較大的侷限性。

首先,我們可以嘗試一下多個字元同時出現這類情形中最基本的特例,即一次出現兩個數字的情況。請啟動手寫數字識別部落格中構建的應用,並在現有的應用裡一次寫下兩個數字,看看識別效果(為了更方便書寫及展示效果,我們將前一示例中筆畫的寬度由40調整為20。可以體驗出這一改動對單個數字的識別並無大的影響):

識別範圍只是單個數字

上圖是一次試驗的結果。進行多次試驗,我們看到現有應用對兩個數字的識別效果不盡人意。

如上圖所示,應用視窗右上角展示的結果準確地反應了模型對我們手寫輸入的推理結果(即result.First().First().ToString()),然而這一結果並不像期望的那樣,是我們在左側繪圖區寫下的“42”。

其實對這個現象的解釋已經蘊含在我們之前的部落格內容中了。在“手寫識別應用入門”的模型介紹章節中,我們對用於訓練模型的MNIST資料集做了大致的介紹。歸根結底,上述現象的癥結在於:作為我們人工智慧應用核心的模型,本身並不具備識別多個數字的能力——作為模型的源頭,也即是訓練資料的MNIST資料集只覆蓋了單個的手寫數字。並且,在應用的輸入處理部分,我們並未對筆跡圖形作額外的處理。

這兩點的綜合結果就是,在寫下多個數字的情況下,我們實際上在“強行”讓AI模型做超出其適應性範圍的推理。這屬於AI模型的誤用。其結果自然難以令人滿意。

那麼,為了增強應用的可用性,我們能不能改善這款應用,讓其能處理常見的數學表示式呢?這要求我們的應用既能識別數字和符號,又能識別同時出現的多個字元:首先對於多個數字這種情況,我們很自然地想到,既然MNIST模型已經能很好地識別單個數字,那我們只需要把多個數字分開,一個一個地讓MNIST模型進行識別就好了;對於識別其他數學符號,我們可以嘗試通過擴充套件MNIST模型的識別範圍,也即擴充套件MNIST資料集來實現。兩者合二為一,就是一種非常可行的解決方案。這樣,我們就引入了兩個新的子問題,即“擴充MNIST資料集”和“多個手寫字元的分割”。

結合上文陳述的問題和潛在的解決方案,本文將以“識別並計算簡單的數學表示式”這一問題為導向,對現有的手寫數字識別應用進行擴充套件。

我們的目標是對克服現存的只能對單個數字進行識別這一侷限,讓新應用可以識別數字、加減乘除和括號這些能構成簡單數學表示式的元素,並對識別出的數學表示式進行計算。本文希望通過這些,能最終獲得一款更具現實意義的人工智慧應用。

最終的應用效果如下圖:

應用效果

注意

“識別可能出現的多種字元”和“識別同時出現的多個字元”是完全不同的,請注意區別。

子問題:擴充套件MNIST資料集

準備資料

資料格式

為了讓我們的新模型能支援除了數字以外的字元,一個簡單的做法是擴充套件MNIST資料集並嘗試複用已有的模型訓練演算法(卷積神經網路)。在“手寫識別應用入門”的資料預處理章節中,我們部分了解了MNIST資料集所採用的資料格式和規範。為了儘可能地複用已有的資源,我們有必要讓擴充套件的那部分資料貼近原始的MNIST資料。

Samples-for-ai樣例庫中使用的MNIST示例,在初執行時會從http://yann.lecun.com/exdb/mnist/下載MNIST資料集並作為訓練資料。當我們順利執行mnist.py指令碼並完成訓練後,我們可以在samples-for-ai\examples\tensorflow\MNIST\input目錄中看到四個副檔名為.gz的檔案,這四個檔案就是從網上下載下來的MNIST資料集,即手寫數字的點陣圖和標記。不過這些檔案是經過壓縮的資料,我們使用的訓練程式在下載完成後還會對這些壓縮檔案進行解壓。訓練程式只將解壓後的資料儲存在記憶體中,並沒有回寫到硬碟上,所以我們在input目錄下找不到儲存了原始點陣圖資料的檔案。

小提示

我們仍可以使用支援這種壓縮格式的工具將其解壓。並使用二進位制工具檢視其內容。

http://yann.lecun.com/exdb/mnist/頁面上,我們可以瞭解到MNIST資料集的點陣圖檔案和標籤檔案的檔案格式。其中最主要的,是用於訓練的點陣圖都是28x28尺寸的、單通道的灰度圖,前景色(筆畫)對應值為255(按顏色表示即是白色),背景色對應值0(按顏色表示即是黑色)。從之前部落格中我們已經瞭解到,MNIST資料集是取反儲存點陣圖畫素的,如果將其直接顯示為點陣圖,則和我們在介面上所見的白底黑字相反。

注意

http://yann.lecun.com/exdb/mnist/頁面提到0 means background (white), 255 means foreground (black),其對顏色的解釋和一般的色值解釋相反。請注意釐清這兩種顏色的解釋方法。

但是無論視覺上的顏色是什麼,我們真正要遵守的規則是將前景的值轉換到255,背景的值轉換到0(在輸入模型時,分別表現為0.5和-0.5)。

結合頁面上的描述和mnist.py中資料預處理部分的相關邏輯,我們瞭解到目前使用的卷積神經網路要求的最終的輸入資料格式如下:

影象資料 標記資料
四維陣列 一維陣列
第一維大小為輸入的圖片總數;
第二維、第三維大小為輸入點陣圖的寬高,此處皆為28;
第四維大小為輸入點陣圖的顏色通道數,MNIST只使用灰度圖片,故為1。
大小為輸入的圖片總數。
每個元素(在第四維)都是32位浮點數。取值大於等於-0.5,小於等於0.5。其中0.5表示前景畫素的最大值,-0.5表示背景畫素的最大值。 每個元素都是64位整數。取值0-9,分別代表對應的手寫0-9的數字。

根據上述的輸入格式,我們已經可以確定我們擴充套件訓練資料的方向了。這裡我們需要注意,這些格式是最終輸入到卷積神經網路的資料必須滿足的,而非我們即將蒐集、準備的新資料。雖然這表明了我們新蒐集的資料不一定要精確地滿足這些條件,這些輸入格式仍然對我們的資料蒐集起到重要的指導作用。

下圖是按照一般的畫素值-視覺顏色對應關係,將MNIST中的資料、UI上繪製的資料、作為模型輸入的資料三者視覺化之後的示意圖。圖上還標記了畫素值到輸入資料格式中32位浮點數的對映方式(上方的橙色箭頭),以便形象地展示這一轉換過程(F表示 Foreground 即前景,B表示 Background 即背景):

前景色和背景色示意圖

UI上繪製的資料的格式,同時也是我們下文中要準備的新資料的格式。之所以使用和MNIST內部資料相反的形式,是為了在介面和其他地方進行視覺化展示時,更符合一般的書寫習慣。之後我們可以在進行訓練之前,再進行反色操作,如同我們上一課處理從介面上來的點陣圖資料一樣。

收集並格式化資料

蒐集資料的方式多種多樣。就本文的需要來說,我們可以在網路上搜索已有的資料集,可以自行開發小型應用以在觸控式螢幕甚至手機上搜集手寫圖形,或者掃描手寫文件並通過影象分割的方式提取運算子。並且,在蒐集完原始資料之後,我們還可以通過縮放、扭曲、新增噪點等方式來擴充套件、增強我們的資料集,以獲得更廣泛的適應性。

在我們蒐集足夠多的新圖片後(考慮到原始MNIST資料集共70000張圖片,我們蒐集40000張左右比較合適,雖然數量不是絕對的),我們還需要對其進行一定的格式化,以方便我們最終將其作為神經網路的輸入。

點陣圖部分所需的處理非常直觀。我們可以參考前一篇手寫數字識別部落格中對應用圖形介面上捕獲的手寫圖形的處理方式,將蒐集的圖片(可能具有RGB通道)轉換成28x28畫素的、單通道的灰度圖片,並且前景色(即筆畫)色值為0(黑色),背景色色值為255(白色)。符合要求的點陣圖樣例如下:

合適的點陣圖示例

此處更需要注意的是對圖片標記的處理。在原始MNIST資料集中我們看到整數0-9被用來標記對應的圖形,這是非常自然的做法。因為我們此處要解決的是多分類問題,解決這類問題的一個先決條件就是我們必須為每個分類提供一一對應的標記。我們很容易就想到用諸如10、11、12來標記加號、減號、乘號等圖形類別。這是可行的。

此處我們不由得思考,延續已被佔用的自然數取標記新類別,雖然可行,但讓對應關係變得混亂了。10和加號、11和減號之間,並不像0-9的整數和圖形之間有那麼自然的聯絡。作為開發者的我們不禁想到,能否用ASCII表裡加減乘除的字元對應的數值來做標記呢(如加號對應53,減號對應55)?這種標記的設定方法實際上是很難使用的,特別是本文中出現的MNIST訓練程式基於的是TensorFlow框架,框架本身要求了標記佔用的整數值必須小於類別總數。在保證標記和類別一一對應的前提條件下,我們接著已有0-9標記,再為我們新蒐集的圖形類別增加標記。此時我們需要清楚的定義標記到類別的對應關係,以便我們正確處理模型的輸入和輸出。

我們用10-15分別表示加號、減號、乘號、除號、正括號、反括號。並且,為了便於訓練,我們要求這六種數學符號對應的點陣圖,分別放置於add、minus、mul、div、lp、rp這六個資料夾中,並且這六個資料夾需要在同一個目錄下。如下圖所示:

需要的六個資料夾

訓練模型

為了支援我們新增的六種數學符號,我們需要修改原始的MNIST模型訓練指令碼(即之前所用的mnist.py)。

我們已將應用的主體程式碼和訓練新模型所需的程式碼上傳到 GitHub,可以通過下面的命令將這些程式碼克隆到本地:

git clone https://github.com/MS-UAP/mnist_extenstion

這一倉庫包含兩部分內容:

mnist_extension.py 指令碼就是我們這一節需要使用的,新模型的訓練指令碼。這一指令碼要求額外的命令列引數--extension_dir,用於指定我們擴充套件的六種數學符號的點陣圖所在;

上文中,我們要求新蒐集的資料最後需要被格式化為以色值0(黑色)為前景色,以色值255(白色)為前景色的單通道點陣圖。我們修改後的訓練指令碼會讀取這些點陣圖並其反色,以到達和原始MNIST資料同樣的效果(也和我們應用中輸入處理的部分一致)。
假設我們存放add、minus等六個資料夾的目錄是D:\extension_images,我們就可以在克隆好了的倉庫的/training目錄下,通過命令列執行:

python mnist_extension.py --extension_dir D:\extension_images

來啟動針對包含了六種數學符號的擴充套件資料集的訓練。該訓練指令碼在匯入原始MNIST資料之後,還會從D:\extension_images目錄分別讀取六種新類別的資料。再混合新舊資料之後進行訓練。可能的訓練結果如下圖:

訓練結果

小提示

混合新舊資料在這裡非常有用。因為訓練過程中,目前的指令碼是一次僅將一部分資料用於迭代優化和模型引數更新。如果不進行混合,就會發生新資料遲遲不被利用的情況,影響模型的訓練結果。

我們對MNIST模型的訓練是基於卷積神經網路的。並且上本中的指令碼在處理擴充套件的符號點陣圖之外,並沒有對用於訓練原始MNIST模型的卷積神經網路的結構進行修改。我們知道系統的結構決定其功能,那麼我們針對原始MNIST資料設計的網路結構能否支撐擴充套件後的資料集呢?對這一問題最簡單的回答就是進行一次訓練並觀察模型效能。

用這種方法進行試驗後,我們通過錯誤率(主要是Validation error,在此例中反映了每100次小批量訓練之後,模型當前在整個驗證集上的錯誤率;和Test error,在此例中反映了訓練結束後模型在整個測試集上的錯誤率)發現新模型的效能還是不錯的。足以支援我們接下來的應用。

子問題:分割多個手寫字元

如上文所述,我們為了對多個同時出現的字元進行識別,還必須解決一個子問題,那就是要對這些同時出現的字元進行分割。

我們注意到本文介紹的應用有一個特點,那就是最終用作輸入的圖形,是使用者當場寫下的,而非通過圖片檔案匯入的靜態圖片。也就是說,我們擁有筆畫產生過程中的全部動態資訊,比如筆畫的先後順序,筆畫的重疊關係等等。而且我們期望這些筆畫基本都是橫向書寫的。考慮到這些資訊,我們可以設計一種基本的分割規則:在水平面上的投影相重疊的筆畫,我們就認為它們同屬於一個數字。

筆畫和水平方向上投影的關係示意如下圖:

投影示意圖

因此書寫時,就要求不同的數字之間儘量隔開。當然為了儘可能處理不經意的重疊,我們還可以為重疊部分相對每一筆畫的位置設定一個閾值,如至少進入筆畫一端的10%以內。

加入對重疊的容忍閾值後,對筆畫的分割的結果可以參看下圖。在分割後被認為是屬於同一字元的筆畫我們使用了相同的顏色繪製,並且用不同的顏色區分了不屬於同一字元的筆畫。在字元的上方,我們用一系列水平方向的半透明色塊表現了每一筆畫在水平方向上的的有效重疊區域和字元之間的重疊關係。

分組示意圖

應用這樣的規則後,我們就能簡便而又有效地對多個筆畫進行分割,並能利用Visual Studio Tools for AI提供的批量推理功能,一次性對所有分割出的圖形做推理。

應用的構建和理解

完成應用

在上文中克隆的 mnist_extenstion 倉庫中已經存在該應用的主體應用程式碼了。本文將只通過引用模型來完成本文中這款應用。之後會對主要的程式碼進行分析。

按照訓練模型一節中所述,完成克隆後,我們可以通過Visual Studio開啟克隆目錄下的MnistDemo.sln解決方案,並和上一課一樣,在解決方案裡新增AI Tools – Inference模型專案。不過與上一課程稍有不同的是,為了對我們擴充套件的新模型加以區分,我們需要將新模型專案命名為ExtendedModel(同時也是預設的名稱空間名字),並將新的模型包裝類命名為MnistExtension。並且這一次,在模型專案建立嚮導中,我們需要選擇上文中訓練出的新模型。

新的Inference模型專案和模型包裝類配置如下圖:

Inference專案

Inference嚮導

理解程式碼

輸入處理

在新應用的程式碼部分,和我們在手寫數字識別部落格中介紹的程式碼比起來,差別最大的地方就在於如何處理輸入。在上個案例中,我們只需要簡單地將正方形區域中的影象格式調整一下,即可用作MNIST模型的輸入。而在本文的案例中,我們必須先對筆畫進行分割處理。分割筆畫之後我們再將每一個筆畫組合轉換成MNIST模型所需的單個輸入。

新應用需要響應的介面事件,還是和之前一致:需要響應滑鼠的按下、移動和擡起三類事件。我們對其中按下和移動的響應事件的修改比較簡單,我們只需要在這些響應時間裡對新寫下的筆畫做記錄就好了。這些程式碼都位於 MNIST.App 目錄下的 MainWindow.cs 檔案中。

記錄筆畫的產生過程

首先我們為窗體類新增一個List<Point>型別的欄位,用於記錄每次滑鼠按下、擡起之間滑鼠移動過的點,將這些點按順序連線起來就形成了一道筆畫。我們在滑鼠按下事件裡清空以前記錄的所有滑鼠移動點,以便記錄這次書寫產生的新一動點;並在滑鼠擡起事件裡將這些點轉換成筆畫對應的資料結構StrokeRecord(定義見後文)。同樣的,我們也為窗體類新增一個List<StrokeRecord>型別的欄位,用於記錄已經寫下的所有筆畫。

private List<Point> strokePoints = new List<Point>();
private List<StrokeRecord> allStrokes = new List<StrokeRecord>();

writeArea_MouseDown方法中新增以下語句用於清空以前記錄的滑鼠移動點:

strokePoints.Clear();

並在writeArea_MouseMove方法中記錄滑鼠這次移動所到達的點:

strokePoints.Add(e.Location);

writeArea_MouseUp方法裡將這次滑鼠按下、擡起之間產生的所有點轉換成筆畫對應的資料結構。並且因為如果滑鼠在擡起之前並沒有移動,就不會有點被記錄,在這之前我們還通過strokePoints.Any()先判斷一下是否有點被記錄。下面是轉化移動點的程式碼:

var thisStrokeRecord = new StrokeRecord(strokePoints);
allStrokes.Add(thisStrokeRecord);

包括建構函式在內的StrokeRecord結構定義如下:

/// <summary>
/// 用於記錄歷史筆畫資訊的資料結構。
/// </summary>
class StrokeRecord
{
    public StrokeRecord(List<Point> strokePoints)
    {
        // 拷貝所有Point以避免列表在外部被修改。
        Points = new List<Point>(strokePoints);

        HorizontalStart = Points.Min(pt => pt.X);
        HorizontalEnd = Points.Max(pt => pt.X);
        HorizontalLength = HorizontalEnd - HorizontalStart;

        OverlayMaxStart = HorizontalStart + (int)(HorizontalLength * (1 - ProjectionOverlayRatioThreshold));
        OverlayMinEnd = HorizontalStart + (int)(HorizontalLength * ProjectionOverlayRatioThreshold);
    }

    /// <summary>
    /// 構成這一筆畫的點。
    /// </summary>
    public List<Point> Points { get; }

    /// <summary>
    /// 這一筆畫在水平方向上的起點。
    /// </summary>
    public int HorizontalStart { get; }

    /// <summary>
    /// 這一筆畫在水平方向上的終點。
    /// </summary>
    public int HorizontalEnd { get; }

    /// <summary>
    /// 這一筆畫在水平方向上的長度。
    /// </summary>
    public int HorizontalLength { get; }

    /// <summary>
    /// 另一筆畫必須越過這些閾值點,才被認為和這一筆畫重合。
    /// </summary>
    public int OverlayMaxStart { get; }
    public int OverlayMinEnd { get; }

    private bool CheckPosition(StrokeRecord other)
    {
        return (other.HorizontalStart < OverlayMaxStart) || (OverlayMinEnd < other.HorizontalEnd);
    }

    /// <summary>
    /// 檢查另一筆畫是否和這一筆畫重疊。
    /// </summary>
    /// <param name="other"></param>
    public bool OverlayWith(StrokeRecord other)
    {
        return this.CheckPosition(other) || other.CheckPosition(this);
    }
}

分割筆畫

在將新產生的筆畫新增到所有筆畫的列表中之後,我們就有了當前使用者寫下的所有筆畫了,接下來我們要對這些筆畫進行分組。

本文在這裡對上文所述的“快速”分割的實現非常簡單。在按筆畫在水平方向上最左端的座標,將筆畫有小到大排序後,我們從最左邊開始掃描所有筆畫。如果一個筆畫還沒有分組,我們就為它指定唯一分組編號,然後再看其右側有哪些筆畫和當前筆畫在水平方向上的投影是有效重合的(如上文所述,此處有閾值10%),並將這些重合的筆畫定為屬於同一組。直到所有筆畫都被掃描。

allStrokes = allStrokes.OrderBy(s => s.HorizontalStart).ToList();
int[] strokeGroupIds = new int[allStrokes.Count];
int nextGroupId = 1;

for (int i = 0; i < allStrokes.Count; i++)
{
    // 為了避免水平方向太多筆畫被連在一起,我們採取一種簡單的辦法:
    // 當1、2筆畫重疊時,我們就不會在檢查筆畫2和更右側筆畫是否重疊。
    if (strokeGroupIds[i] != 0)
    {
        continue;
    }

    strokeGroupIds[i] = nextGroupId;
    nextGroupId++;

    var s1 = allStrokes[i];
    for (int j = 1; i + j < allStrokes.Count; j++)
    {
        var s2 = allStrokes[i + j];

        if (s2.HorizontalStart < s1.OverlayMaxStart) // 先判斷臨界條件(閾值10%)
        {
            if (strokeGroupIds[i + j] == 0)
            {
                if (s1.OverlayWith(s2)) // 在考慮閾值的條件下做完整地判斷重合
                {
                    strokeGroupIds[i + j] = strokeGroupIds[i];
                }
            }
        }
        else
        {
            break;
        }
    }
}

之後即可按對應的分組編號將筆畫歸組:

List<IGrouping<int, StrokeRecord>> groups = allStrokes
    .Zip(strokeGroupIds, Tuple.Create)
    .GroupBy(tuple => tuple.Item2, tuple => tuple.Item1) // Item2是分組編號, Item1是StrokeRecord
    .ToList();

小提示

為了方便理解筆畫的分割效果,應用介面上預留了“顯示筆畫分組”的開關。勾選之後寫下的筆畫會像上文那樣被不同的顏色標記出其所在的分組。

為每個分組生成單一點陣圖

分割完成後,我們得到了一個數組groups,它的每個元素都是一個分組,包括了分組編號和組內的所有筆畫。這裡我們得到的每一個分組都對應著一個字元。如果分組裡有多個筆畫,那麼這些筆畫就是這個字元的組成部分(想象加號和乘號,它們都需要兩筆才能寫成)。我們可以想到,這個陣列groups裡的元素的順序是很重要的,因為我們要保證最終識別出的表示式裡的字元的順序,才能正確地計算表示式。

我們在迴圈中順序訪問groups的每個元素。命名迴圈變數為group

foreach (IGrouping<int, StrokeRecord> group in groups)

迴圈變數group的型別是IGrouping<int, StrokeRecord>,它代表著一個分組,包括分組的編號(一個整數)和其中的元素(元素都是StrokeRecord)。IGrouping<TKey, TElement>泛型介面同時也是一個可迭代的IEnumerable<TElement>泛型介面,所以我們可以把group變數直接當做IEnumerable<StrokeRecord>型別的物件來使用。

然後我們需要確定這個分組(即其中所有筆畫組合成的圖形)的位置區域,其中我們最關心水平方向上最左端、最右端的座標(水平方向的座標軸是從左向右的)。

通過這兩個座標我們就能確定該分組在水平方向上的投影的長度。我們計算這個長度的目的,是為了在我們為每個分組生成單一點陣圖時,儘量將這個分組的圖形放置在單一點陣圖的中間位置。雖然我們還是先建立一個大尺寸的正方形點陣圖(邊長為繪圖區高度),但是分割後的圖形在這個正方形區域上不再具有天然的位置。下面的程式碼進行了這些位置的計算,和居中該分組所需的水平方向的偏移量的計算:

var groupedStrokes = group.ToList(); // IGrouping<TKey, TElement>本質上也是一個可迭代的IEnumerable<TElement>

// 確定整個分組的所有筆畫的範圍。
int grpHorizontalStart = groupedStrokes.Min(s => s.HorizontalStart);
int grpHorizontalEnd = groupedStrokes.Max(s => s.HorizontalEnd);
int grpHorizontalLength = grpHorizontalEnd - grpHorizontalStart;

int canvasEdgeLen = writeArea.Height;
Bitmap canvas = new Bitmap(canvasEdgeLen, canvasEdgeLen);
Graphics canvasGraphics = Graphics.FromImage(canvas);
canvasGraphics.Clear(Color.White);

// 因為我們提取了每個筆畫,就不能把長方形的繪圖區直接當做輸入了。
// 這裡我們把寬度小於 writeArea.Height 的分組在 canvas 內居中。
int halfOffsetX = Math.Max(canvasEdgeLen - grpHorizontalLength, 0) / 2;

之後我們就在新創建出的點陣圖上繪製當前分組內的筆畫了(通過canvasGraphics物件進行繪製):

foreach (var stroke in groupedStrokes)
{
    Point startPoint = stroke.Points[0];
    foreach (var point in stroke.Points.Skip(1))
    {
        var from = startPoint;
        var to = point;

        // 因為每個分組都是在長方形的繪圖區被記錄的,所以在單一點陣圖上,需要先減去相對於長方形繪圖區的偏移量 grpHorizontalStart
        from.X = from.X - grpHorizontalStart + halfOffsetX;
        to.X = to.X - grpHorizontalStart + halfOffsetX;
        canvasGraphics.DrawLine(penStyle, from, to);

        startPoint = point;
    }
}

批量推理

在新應用中,我們一次需要識別多個字元。而以前我們一次只需要識別一個字元,哪怕我們每次都為了識別一個字元呼叫了一次模型的推理方法(model.Infer(...))。

不過我們現在已經準備好了多組資料,這使得我們有機會利用底層AI框架的並行處理能力,來加速我們的推理過程,還省去了手動處理多執行緒的麻煩。在這裡我們採用Visual Studio Tools for AI提供的批量推理功能,一次對所有資料進行推理並得到全部結果。

首先我們在為所得分組建立點陣圖之前,需要先建立一個用於儲存所有資料的動態陣列:

var batchInferInput = new List<IEnumerable<float>>();

在處理所有分組的迴圈內部,處理完每個分組後,我們需要將該分組對應的畫素資料暫時存放在動態陣列batchInferInput中:

// 1. 將分割出的筆畫圖片縮小至 28 x 28,與訓練資料格式一致。
Bitmap clonedBmp = new Bitmap(canvas, ImageSize, ImageSize);

var image = new List<float>(ImageSize * ImageSize);
for (var x = 0; x < ImageSize; x++)
{
    for (var y = 0; y < ImageSize; y++)
    {
        var color = clonedBmp.GetPixel(y, x);
        image.Add((float)(0.5 - (color.R + color.G + color.B) / (3.0 * 255)));
    }
}

// 將這一組筆畫對應的矩陣儲存下來,以備批量推理。
batchInferInput.Add(image);

可以看到我們對每個分組的處理,都和以前對整個正方形繪圖區的畫素的處理,是完全一致的。唯一的不同是在以前的應用程式碼中,List<IEnumerable<float>>型別的陣列(在上文中為batchInferInput變數)僅有一個元素,就是唯一一張點陣圖的畫素資料。而在本文中這個陣列可能有很多元素,每個元素都是一組點陣圖資料。對這樣的點陣圖資料集合進行批量推理後,得到的結果(即inferResult變數)是一個可列舉的型別,我們叫它“第一層列舉”。第一層列舉得到的每個元素也是一個可列舉型別,我們叫它“第二層列舉”。

第一層列舉中的每個元素都對應著一組點陣圖資料的推理結果。同時第一層列舉也是對應著批量推理的輸入陣列,列舉的結果總數和輸入陣列的長度相同。對於第二層列舉,由於我們的推理結果只是一個整數,所以第二層列舉總是隻有一個元素。我們可以通過.First()將其取出。這裡我們可以看到,在以前的應用程式碼裡,我們通過inferResult.First().First()取出了唯一的結果,而在這裡我們則需要考慮批量推理結果的二維結構。

進行推理的程式碼如下:

// 2. 進行批量推理
//    batchInferInput 是一個列表,它的每個元素都是一次推量的輸入。
IEnumerable<IEnumerable<long>> inferResult = model.Infer(batchInferInput);

//    推量的結果是一個可列舉物件,它的每個元素代表了批量推理中一次推理的結果。我們用 僅一次.First() 將它們的結果都取出來,並格式化。
outputText.Text = string.Join("", inferResult.Select(singleResult => singleResult.First().ToString()));

計算表示式

至此,我們對於多個手寫字元的識別就完成了。我們已經得到了可以表示使用者手寫圖形的、易於計算機程式處理的字串。接下來我們開始對字串記載的數學表示式進行計算。

本文需要計算的數學表示式的格式,由上文的資料準備和模型訓練部分可知,是相對簡單的。其中只涉及數字0-9、加減乘除和小括號。對這樣的表示式進行求值,是一種非常典型的問題。因為這樣的數學表示式有非常清晰、確定的語法規則,對其最直觀的處理方法,就是先根據其語法進行解析,構造語法樹後進行求值即可。或者,因為這種問題非常經典,我們也可以尋找已有的元件來解決這個問題。

本文直接複用System.Data.DataTable類提供的Compute方法來進行表示式的計算。這個方法完全支援本文案例中出現的表示式語法。

因為表示式的計算這部分邏輯邊界非常清晰,我們引入一個獨立的方法來獲取最後的結果:

string EvaluateAndFormatExpression(List<int> recognizedLabels)

EvaluateAndFormatExpression方法接受一個標籤序列,其中我們仍在用整數10-15來表示各種數學符號。在這個方法內我們對字元標籤做兩種對映,分別將標籤序列轉換成用於輸入到計算器進行求值的,和用於在使用者介面上展示的。EvaluateAndFormatExpression方法的返回結果形如“(3+2)÷2=2.5”。其中各種符號皆採用傳統的數學寫法。該方法的實現如下:

private string EvaluateAndFormatExpression(List<int> recognizedLabels)
{
    string[] operatorsToEval = { "+", "-", "*", "/", "(", ")" };
    string[] operatorsToDisplay = { "+", "-", "×", "÷", "(", ")" };

    string toEval = string.Join("", recognizedLabels.Select(label =>
    {
        if (0 <= label && label <= 9)
        {
            return label.ToString();
        }

        return operatorsToEval[label - 10];
    }));

    var evalResult = new DataTable().Compute(toEval, null);
    if (evalResult is DBNull)
    {
        return "Error";
    }
    else
    {
        string toDisplay = string.Join("", recognizedLabels.Select(label =>
        {
            if (0 <= label && label <= 9)
            {
                return label.ToString();
            }

            return operatorsToDisplay[label - 10];
        }));

        return $"{toDisplay}={evalResult}";
    }
}

同時需要注意的是,根據表示式求值方案的不同,我們可能需要對錶達式中的字元進行對應的調整。比如當我們希望在使用者介面上將除號顯示為更可讀的“÷”時,我們採用的求值方案可能並不支援這種除號,而只支援C#語言中的除號/。那麼我們在將識別出的結果輸入到表示式計算器中之前,還需要對識別的結果進行合適的對映。

常見問題

新模型對括號和數字1的識別很差

這是一種非常容易出現的情況。因為在手寫時,正反小括號和數字1極易混淆。這一問題有時會在擴充套件資料中體現。我們觀察到原始MNIST資料集中(參見上文的資料視覺化),很多數字1的形狀和彎曲程度已經和括號相近。如果我們在擴充套件資料部分不做明顯的區分,並且我們採用的卷積神經網路對這樣微小的資料差別不敏感的話,就會導致造型相近的字元被錯誤識別的情況。

同理,這樣的問題還可能發生在加號和乘號之間。因為加號和乘號的形狀基本完全一樣,只是靠角度得以區分。如果我們蒐集的擴充套件資料裡,這兩種符號各自都具有一定的旋轉角度,以致角度區分不夠明顯,這也會導致模型對其識別能力不強的情況出現。

擴充套件問題

經過一番擴充套件,我們的新應用已經具備一些不錯的功能,初步滿足了現實規格的應用需求。從本文的案例中,我們也能得到關於如何將人工智慧和傳統的技術手段融合起來,幫助我們更好地解決問題的一些啟示。當然,這款新應用仍然不夠強大和健壯。對此,我們注意到有這樣一些問題仍待解決:

  • 筆畫分割的演算法相對比較簡單、粗糙,如何提升整體的分割效果,以順利處理重疊、連筆、噪點等可能情況?
  • 作為一款計算器應用,本文介紹的新應用具備的特性還是很少。如何增加新的數學計算特性,比如開根號、分數或者更多的數學符號?