主頁 >  其他 > 寫給程式員的機器學習入門 (八) - 卷積神經網路 (CNN) - 圖片分類和驗證碼識別

寫給程式員的機器學習入門 (八) - 卷積神經網路 (CNN) - 圖片分類和驗證碼識別

2020-09-11 02:05:43 其他

這一篇將會介紹卷積神經網路 (CNN),CNN 模型非常適合用來進行圖片相關的學習,例如圖片分類和驗證碼識別,也可以配合其他模型實作 OCR,

使用 Python 處理圖片

在具體介紹 CNN 之前,我們先來看看怎樣使用 Python 處理圖片,Python 處理圖片最主要使用的類別庫是 Pillow (Python2 PIL 的 fork),使用以下命令即可安裝:

pip3 install Pillow

一些簡單操作的例子如下,如果你想了解更多可以參考 Pillow 的檔案:

# 打開圖片
>>> from PIL import Image
>>> img = Image.open("1.png")

# 查看圖片資訊
>>> img.size
(175, 230)
>>> img.mode
'RGB'
>>> img
<PIL.PngImagePlugin.PngImageFile image mode=RGB size=175x230 at 0x10B807B50>

# 縮放圖片
>>> img1 = img.resize((20, 30))
>>> img1
<PIL.Image.Image image mode=RGB size=20x30 at 0x106426FD0>

# 裁剪圖片
>>> img2 = img.crop((0, 0, 16, 16))
>>> img2
<PIL.Image.Image image mode=RGB size=16x16 at 0x105E0EFD0>

# 保存圖片
>>> img1.save("11.png")
>>> img2.save("12.png")

使用 pytorch 處理圖片時要首先獲取圖片的資料,即各個像素對應的顏色值,例如大小為 175 * 230,模式是 RGB 的圖片會擁有 175 * 230 * 3 的資料,3 分別代表紅綠藍的值,范圍是 0 ~ 255,把圖片轉換為 pytorch 的 tensor 物件需要經過 numpy 中轉,以下是轉換的例子:

>>> import numpy
>>> import torch
>>> v = numpy.asarray(img)
>>> t = torch.tensor(v)
>>> t
tensor([[[255, 253, 254],
         [255, 253, 254],
         [255, 253, 254],
         ...,
         [255, 253, 254],
         [255, 253, 254],
         [255, 253, 254]],

        [[255, 253, 254],
         [255, 253, 254],
         [255, 253, 254],
         ...,
         [255, 253, 254],
         [255, 253, 254],
         [255, 253, 254]],

        [[255, 253, 254],
         [255, 253, 254],
         [255, 253, 254],
         ...,
         [255, 253, 254],
         [255, 253, 254],
         [255, 253, 254]],

        ...,

        [[255, 253, 254],
         [255, 253, 254],
         [255, 253, 254],
         ...,
         [255, 253, 254],
         [255, 253, 254],
         [255, 253, 254]],

        [[255, 253, 254],
         [255, 253, 254],
         [255, 253, 254],
         ...,
         [255, 253, 254],
         [255, 253, 254],
         [255, 253, 254]],

        [[255, 253, 254],
         [255, 253, 254],
         [255, 253, 254],
         ...,
         [255, 253, 254],
         [255, 253, 254],
         [255, 253, 254]]], dtype=torch.uint8)
>>> t.shape
torch.Size([230, 175, 3])

可以看到 tensor 的維度是 高度 x 寬度 x 通道數 (RGB 圖片為 3,黑白圖片為 1),可是 pytorch 的 CNN 模型會要求維度為 通道數 x 寬度 x 高度,并且數值應該正規化到 0 ~ 1 的范圍內,使用以下代碼可以實作:

# 交換維度 0 (高度) 和 維度 2 (通道數)
>>> t1 = t.transpose(0, 2)
>>> t1.shape
torch.Size([3, 175, 230])

>>> t2 = t1 / 255.0
>>> t2
tensor([[[1.0000, 1.0000, 1.0000,  ..., 1.0000, 1.0000, 1.0000],
         [1.0000, 1.0000, 1.0000,  ..., 1.0000, 1.0000, 1.0000],
         [1.0000, 1.0000, 1.0000,  ..., 1.0000, 1.0000, 1.0000],
         ...,
         [1.0000, 1.0000, 1.0000,  ..., 1.0000, 1.0000, 1.0000],
         [1.0000, 1.0000, 1.0000,  ..., 1.0000, 1.0000, 1.0000],
         [1.0000, 1.0000, 1.0000,  ..., 1.0000, 1.0000, 1.0000]],

        [[0.9922, 0.9922, 0.9922,  ..., 0.9922, 0.9922, 0.9922],
         [0.9922, 0.9922, 0.9922,  ..., 0.9922, 0.9922, 0.9922],
         [0.9922, 0.9922, 0.9922,  ..., 0.9922, 0.9922, 0.9922],
         ...,
         [0.9922, 0.9922, 0.9922,  ..., 0.9922, 0.9922, 0.9922],
         [0.9922, 0.9922, 0.9922,  ..., 0.9922, 0.9922, 0.9922],
         [0.9922, 0.9922, 0.9922,  ..., 0.9922, 0.9922, 0.9922]],

        [[0.9961, 0.9961, 0.9961,  ..., 0.9961, 0.9961, 0.9961],
         [0.9961, 0.9961, 0.9961,  ..., 0.9961, 0.9961, 0.9961],
         [0.9961, 0.9961, 0.9961,  ..., 0.9961, 0.9961, 0.9961],
         ...,
         [0.9961, 0.9961, 0.9961,  ..., 0.9961, 0.9961, 0.9961],
         [0.9961, 0.9961, 0.9961,  ..., 0.9961, 0.9961, 0.9961],
         [0.9961, 0.9961, 0.9961,  ..., 0.9961, 0.9961, 0.9961]]])

之后就可以圍繞類似上面例子中 t2 這樣的 tensor 物件做文章了??,

卷積神經網路 (CNN)

卷積神經網路 (CNN) 會從圖片的各個部分提取特征,然后再從一級特征提取二級特征,如有必要再提取三級特征 (以此類推),提取結束以后扁平化到最終特征,然后使用多層或單層線性模型來實作分類識別,提取各級特征會使用卷積層 (Convolution Layer) 和池化層 (Pooling Layer),提取特征時可以選擇添加通道數量以增加各個部分的資訊量,分類識別最終特征使用的線性模型又稱全連接層 (Fully Connected Layer),下圖是流程示例:

之前的文章介紹線性模型和遞回模型的時候我使用了數學公式,但只用數學公式說明 CNN 將會非常難以理解,所以接下來我會伴隨例子逐步講解各個層具體做了怎樣的運算,

卷積層 (Convolution Layer)

卷積層會對圖片的各個部分做矩陣乘法操作,然后把結果作為一個新的矩陣,每個卷積層有兩個主要的引數,一個是內核大小 (kernel_size),一個是處理間隔 (stride),下圖是一個非常簡單的計算流程例子:

如果增加處理間隔會怎樣呢?下圖展示了不同處理間隔的計算部分和輸出結果維度的區別:

我們可以看到處理間隔決定了每次向右或者向下移動的距離,輸出長度可以使用公式 (長度 - 內核大小) / 處理間隔 + 1 計算,輸出寬度可以使用公式 (長度 - 內核大小) / 處理間隔 + 1 計算,

現在再來看看 pytorch 中怎樣使用卷積層,創建卷積層可以使用 torch.nn.Conv2d

# 創建卷積層,入通道 = 1,出通道 = 1,內核大小 = 2,處理間隔 = 1
>>> conv2d = torch.nn.Conv2d(in_channels = 1, out_channels = 1, kernel_size = 2, stride = 1)

# 查看卷積層內部的引數,第一個是內核對應的權重矩陣,第二個是偏移值
>>> p = list(conv2d.parameters())
>>> p
[Parameter containing:
tensor([[[[-0.0650, -0.0575],
          [-0.0313, -0.3539]]]], requires_grad=True), Parameter containing:
tensor([0.1482], requires_grad=True)]

# 現在生成一個 5 x 5,單通道的圖片資料,為了方便理解這里使用了 1 ~ 25,實際應該使用 0 ~ 1 之間的值
>>> x = torch.tensor(list(range(1, 26)), dtype=torch.float).reshape(1, 1, 5, 5)
>>> x
tensor([[[[ 1.,  2.,  3.,  4.,  5.],
          [ 6.,  7.,  8.,  9., 10.],
          [11., 12., 13., 14., 15.],
          [16., 17., 18., 19., 20.],
          [21., 22., 23., 24., 25.]]]])

# 使用卷積層計算輸出
>>> y = conv2d(x)
>>> y
tensor([[[[ -2.6966,  -3.2043,  -3.7119,  -4.2196],
          [ -5.2349,  -5.7426,  -6.2502,  -6.7579],
          [ -7.7732,  -8.2809,  -8.7885,  -9.2962],
          [-10.3115, -10.8192, -11.3268, -11.8345]]]],
       grad_fn=<MkldnnConvolutionBackward>)

# 我們可以模擬一下處理單個部分的計算,看看和上面的輸出是否一致

# 第 1 部分
>>> x[0,0,0:2,0:2]
tensor([[1., 2.],
        [6., 7.]])
>>> (p[0][0,0,:,:] * x[0,0,0:2,0:2]).sum() + p[1]
tensor([-2.6966], grad_fn=<AddBackward0>)

# 第 2 部分
>>> x[0,0,0:2,1:3]
tensor([[2., 3.],
        [7., 8.]])
>>> (p[0][0,0,:,:] * x[0,0,0:2,1:3]).sum() + p[1]
tensor([-3.2043], grad_fn=<AddBackward0>)

# 第 3 部分
>>> (p[0][0,0,:,:] * x[0,0,0:2,2:4]).sum() + p[1]
tensor([-3.7119], grad_fn=<AddBackward0>)

# 一致吧??

到這里你應該了解單通道的卷積層是怎樣計算的,那么多通道呢?如果有多個入通道,那么卷積層的權重矩陣會相應有多份,如果有多個出通道,那么卷積層的權重矩陣數量也會乘以出通道的倍數,例如有 3 個入通道,2 個出通道時,卷積層的權重矩陣會有 6 個 (3 * 2),偏移值會有 2 個,計算規則如下:

部分輸出[出通道1] = 部分輸入[入通道1] * 權重矩陣[0][0] + 部分輸入[入通道2] * 權重矩陣[0][1] + 部分輸入[入通道3] * 權重矩陣[0][2] + 偏移值1
部分輸出[出通道2] = 部分輸入[入通道1] * 權重矩陣[1][0] + 部分輸入[入通道2] * 權重矩陣[1][1] + 部分輸入[入通道3] * 權重矩陣[1][2] + 偏移值2

從計算規則可以看出,出通道越多每個部分可提取的特征數量 (資訊量) 也就越多,但計算量也會相應增大,

最后看看卷積層的數學公式 (基本和 pytorch 檔案的公式相同),現在應該可以理解了吧???

池化層 (Pooling Layer)

池化層的處理比較好理解,它會對每個圖片每個區域進行求最大值或者求平均值等運算,如下圖所示:

現在再來看看 pytorch 中怎樣使用卷積層,創建求最大值的池化層可以使用 torch.nn.MaxPool2d,創建求平均值的池化層可以使用 torch.nn.AvgPool2d

# 創建池化層,內核大小 = 2,處理間隔 = 2
>>> maxPool = torch.nn.MaxPool2d(2, stride=2)

# 生成一個 6 x 6,單通道的圖片資料
>>> x = torch.tensor(range(1, 37), dtype=float).reshape(1, 1, 6, 6)
>>> x
tensor([[[[ 1.,  2.,  3.,  4.,  5.,  6.],
          [ 7.,  8.,  9., 10., 11., 12.],
          [13., 14., 15., 16., 17., 18.],
          [19., 20., 21., 22., 23., 24.],
          [25., 26., 27., 28., 29., 30.],
          [31., 32., 33., 34., 35., 36.]]]], dtype=torch.float64)

# 使用池化層計算輸出
>>> maxPool(x)
tensor([[[[ 8., 10., 12.],
          [20., 22., 24.],
          [32., 34., 36.]]]], dtype=torch.float64)

# 很好理解吧??

# 創建和使用求平均值的池化層也很簡單
>>> avgPool = torch.nn.AvgPool2d(2, stride=2)
>>> avgPool(x)
tensor([[[[ 4.5000,  6.5000,  8.5000],
          [16.5000, 18.5000, 20.5000],
          [28.5000, 30.5000, 32.5000]]]], dtype=torch.float64)

全連接層 (Fully Connected Layer)

全連接層實際上就是多層或單層線性模型,但把特征傳到全連接層之前還需要進行扁平化 (Flatten),例子如下所示:

# 模擬創建一個批次數量為 2,通道數為 3,長寬各為 2 的特征
>>> x = torch.rand((2, 3, 2, 2))
>>> x
tensor([[[[0.6395, 0.6240],
          [0.4194, 0.6054]],

         [[0.4798, 0.4690],
          [0.2647, 0.6087]],

         [[0.5727, 0.7567],
          [0.8287, 0.1382]]],


        [[[0.7903, 0.8635],
          [0.0053, 0.6417]],

         [[0.7093, 0.7740],
          [0.3115, 0.7587]],

         [[0.5875, 0.8268],
          [0.2923, 0.6016]]]])

# 對它進行扁平化,維度會變為 批次數量, 通道數*長*寬
>>> x_flatten = x.view(x.shape[0], -1)
>>> x_flatten
tensor([[0.6395, 0.6240, 0.4194, 0.6054, 0.4798, 0.4690, 0.2647, 0.6087, 0.5727,
         0.7567, 0.8287, 0.1382],
        [0.7903, 0.8635, 0.0053, 0.6417, 0.7093, 0.7740, 0.3115, 0.7587, 0.5875,
         0.8268, 0.2923, 0.6016]])

# 之后再傳給線性模型即可
>>> linear = torch.nn.Linear(in_features=12, out_features=2)
>>> linear(x_flatten)
tensor([[-0.3067, -0.5534],
        [-0.1876, -0.6523]], grad_fn=<AddmmBackward>)

填充處理

在看前面提到的卷積層操作的時候,你可能會發現如果處理間隔 (stride) 小于內核大小 (kernel_size),那么圖片邊緣的像素參與運算的次數會比圖片中間的像素要少,也就是說圖片邊緣對運算結果的影響會更小,如果圖片邊緣的資訊同樣比較重要,那么就會影響預測輸出的精度,為了解決這個問題發明的就是填充處理,填充處理簡單的來說就是在卷積層初期前給圖片的周邊添加 0,如果填充量等于 1,那么長寬會各增加 2,如下圖所示:

在 pytorch 中添加填充處理可以在創建 Conv2d 的時候指定 padding 引數:

# 創建卷積層,入通道 = 1,出通道 = 1,內核大小 = 2,處理間隔 = 1, 填充量 = 1
>>> conv2d = torch.nn.Conv2d(in_channels = 1, out_channels = 1, kernel_size = 2, stride = 1, padding = 1)



使用 CNN 實作圖片分類 (LeNet)

接下來我們試試使用 CNN 實作圖片分類,也就是給出一張圖片讓程式識別里面的是什么東西,使用的資料集是 cifar-10,這是一個很經典的資料集,包含了 60000 張 32x32 的小圖片,圖片有十個分類 (飛機,汽車,鳥,貓,鹿,狗,青蛙,馬,船,貨車),官方下載地址在這里,

需要注意的是,官方下載地址只包含二進制資料,通常很多文章或者教程都會讓我們使用 torchvision.datasets.CIFAR10 等現成的加載器來加載這個資料集,但我不推薦使用這種方法,因為如果我們需要訓練實際業務上的資料,那么肯定不會有現成的加載器可以用,還是得一張張圖片的加載和轉換,所以這里我使用了 cifar-10 的原始圖片庫,然后演示怎樣從代碼加載圖片和標簽,然后轉換到訓練使用的 tensor 物件,

以下的代碼使用了 LeNet 模型,這是 30 年前就已經被提出的模型,結構和本文第一個圖片介紹的一樣,此外還有一些需要注意的地方:

  • cifar-10 官方默認劃分了 50000 張圖片作為訓練集,10000 張圖片作為驗證集;而我的代碼劃分了 48000 張圖片作為訓練集,6000 張圖片作為驗證集,6000 張圖片作為測驗集,所以正確率等資料會和其他文章或者論文不一致
  • 訓練時的損失計算器使用了 CrossEntropyLoss, 這個計算器的特征是要求預測輸出是 onehot,實際輸出是索引值 (只有一個分類是正確輸出),例如圖片分類為 時,預測輸出應該為 [0, 0, 1, 0, 0, 0, 0, 0, 0, 0] 實際輸出應該為 2
  • 轉換各個分類的數值到概率使用了 Softmax 函式, 這個函式必須放在模型之外,如果放在模型內部會導致訓練效果變差,因為 CrossEntropyLoss 損失計算器會盡量讓正確輸出的數值更高,錯誤輸出的數值更低,而不是分別接近 1 和 0,使用 softmax 會干擾損失的計算
import os
import sys
import torch
import gzip
import itertools
import random
import numpy
import json
from PIL import Image
from torch import nn
from matplotlib import pyplot

# 分析目標的圖片大小,全部圖片都會先縮放到這個大小
IMAGE_SIZE = (32, 32)
# 分析目標的圖片所在的檔案夾
IMAGE_DIR = "./cifar"
# 包含所有圖片標簽的文本檔案
IMAGE_LABELS_PATH = "./cifar/labels.txt"

class MyModel(nn.Module):
    """圖片分類 (LeNet)"""
    def __init__(self, num_labels):
        super().__init__()
        # 卷積層和池化層
        self.cnn_model = nn.Sequential(
            nn.Conv2d(3, 6, kernel_size=5), # 維度: B,3,32,32 => B,6,28,28
            nn.ReLU(),
            nn.MaxPool2d(2, stride=2), # 維度: B,6,14,14
            nn.Conv2d(6, 16, kernel_size=5), # 維度: B,16,10,10
            nn.ReLU(),
            nn.MaxPool2d(2, stride=2) # 維度: B,16,5,5
        )
        # 全連接層
        self.fc_model = nn.Sequential(
            nn.Linear(16 * 5 * 5, 120), # 維度: B,120
            nn.ReLU(),
            nn.Dropout(0.1),
            nn.Linear(120, 60), # 維度: B,60
            nn.ReLU(),
            nn.Dropout(0.1),
            nn.Linear(60, num_labels), # 維度: B,num_labels
        )

    def forward(self, x):
        # 應用卷積層和池化層
        cnn_features = self.cnn_model(x)
        # 扁平化輸出的特征
        cnn_features_flatten = cnn_features.view(cnn_features.shape[0], -1)
        # 應用全連接層
        y = self.fc_model(cnn_features_flatten)
        return y

def save_tensor(tensor, path):
    """保存 tensor 物件到檔案"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """從檔案讀取 tensor 物件"""
    return torch.load(gzip.GzipFile(path, "rb"))

def image_to_tensor(img):
    """轉換圖片物件到 tensor 物件"""
    in_img = img.resize(IMAGE_SIZE)
    arr = numpy.asarray(in_img)
    t = torch.from_numpy(arr)
    t = t.transpose(0, 2) # 轉換維度 H,W,C 到 C,W,H
    t = t / 255.0 # 正規化數值使得范圍在 0 ~ 1
    return t

def load_image_labels():
    """讀取圖片分類串列"""
    return list(filter(None, open(IMAGE_LABELS_PATH).read().split()))

def prepare_save_batch(batch, tensor_in, tensor_out):
    """準備訓練 - 保存單個批次的資料"""
    # 切分訓練集 (80%),驗證集 (10%) 和測驗集 (10%)
    random_indices = torch.randperm(tensor_in.shape[0])
    training_indices = random_indices[:int(len(random_indices)*0.8)]
    validating_indices = random_indices[int(len(random_indices)*0.8):int(len(random_indices)*0.9):]
    testing_indices = random_indices[int(len(random_indices)*0.9):]
    training_set = (tensor_in[training_indices], tensor_out[training_indices])
    validating_set = (tensor_in[validating_indices], tensor_out[validating_indices])
    testing_set = (tensor_in[testing_indices], tensor_out[testing_indices])

    # 保存到硬碟
    save_tensor(training_set, f"data/training_set.{batch}.pt")
    save_tensor(validating_set, f"data/validating_set.{batch}.pt")
    save_tensor(testing_set, f"data/testing_set.{batch}.pt")
    print(f"batch {batch} saved")

def prepare():
    """準備訓練"""
    # 資料集轉換到 tensor 以后會保存在 data 檔案夾下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 準備圖片分類到序號的索引
    labels_to_index = { label: index for index, label in enumerate(load_image_labels()) }

    # 查找所有圖片
    image_paths = []
    for root, dirs, files in os.walk(IMAGE_DIR):
        for filename in files:
            path = os.path.join(root, filename)
            if not path.endswith(".png"):
                continue
            # 分類名稱在檔案名中,例如
            # 2598_cat.png => cat
            label = filename.split(".")[0].split("_")[1]
            label_index = labels_to_index.get(label)
            if label_index is None:
                continue
            image_paths.append((path, label_index))

    # 打亂圖片順序
    random.shuffle(image_paths)

    # 分批讀取和保存圖片
    batch_size = 1000
    for batch in range(0, len(image_paths) // batch_size):
        image_tensors = []
        image_labels = []
        for path, label_index in image_paths[batch*batch_size:(batch+1)*batch_size]:
            with Image.open(path) as img:
                t = image_to_tensor(img)
                image_tensors.append(t)
            image_labels.append(label_index)
        tensor_in = torch.stack(image_tensors) # 維度: B,C,W,H
        tensor_out = torch.tensor(image_labels) # 維度: B
        prepare_save_batch(batch, tensor_in, tensor_out)

def train():
    """開始訓練"""
    # 創建模型實體
    num_labels = len(load_image_labels())
    model = MyModel(num_labels)

    # 創建損失計算器
    # 計算單分類輸出最好使用 CrossEntropyLoss, 多分類輸出最好使用 BCELoss
    # 使用 CrossEntropyLoss 時實際輸出應該為標簽索引值,不需要轉換為 onehot
    loss_function = torch.nn.CrossEntropyLoss()

    # 創建引數調整器
    optimizer = torch.optim.Adam(model.parameters())

    # 記錄訓練集和驗證集的正確率變化
    training_accuracy_history = []
    validating_accuracy_history = []

    # 記錄最高的驗證集正確率
    validating_accuracy_highest = -1
    validating_accuracy_highest_epoch = 0

    # 讀取批次的工具函式
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield load_tensor(path)

    # 計算正確率的工具函式
    def calc_accuracy(actual, predicted):
        # 把最大的值當作正確分類,然后比對有多少個分類相等
        predicted_labels = predicted.argmax(dim=1)
        acc = (actual == predicted_labels).sum().item() / actual.shape[0]
        return acc

    # 劃分輸入和輸出的工具函式
    def split_batch_xy(batch, begin=None, end=None):
        # shape = batch_size, channels, width, height
        batch_x = batch[0][begin:end]
        # shape = batch_size
        batch_y = batch[1][begin:end]
        return batch_x, batch_y

    # 開始訓練程序
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根據訓練集訓練并修改引數
        # 切換模型到訓練模式,將會啟用自動微分,批次正規化 (BatchNorm) 與 Dropout
        model.train()
        training_accuracy_list = []
        for batch_index, batch in enumerate(read_batches("data/training_set")):
            # 切分小批次,有助于泛化模型
            training_batch_accuracy_list = []
            for index in range(0, batch[0].shape[0], 100):
                # 劃分輸入和輸出
                batch_x, batch_y = split_batch_xy(batch, index, index+100)
                # 計算預測值
                predicted = model(batch_x)
                # 計算損失
                loss = loss_function(predicted, batch_y)
                # 從損失自動微分求導函式值
                loss.backward()
                # 使用引數調整器調整引數
                optimizer.step()
                # 清空導函式值
                optimizer.zero_grad()
                # 記錄這一個批次的正確率,torch.no_grad 代表臨時禁用自動微分功能
                with torch.no_grad():
                    training_batch_accuracy_list.append(calc_accuracy(batch_y, predicted))
            # 輸出批次正確率
            training_batch_accuracy = sum(training_batch_accuracy_list) / len(training_batch_accuracy_list)
            training_accuracy_list.append(training_batch_accuracy)
            print(f"epoch: {epoch}, batch: {batch_index}: batch accuracy: {training_batch_accuracy}")
        training_accuracy = sum(training_accuracy_list) / len(training_accuracy_list)
        training_accuracy_history.append(training_accuracy)
        print(f"training accuracy: {training_accuracy}")

        # 檢查驗證集
        # 切換模型到驗證模式,將會禁用自動微分,批次正規化 (BatchNorm) 與 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_y = split_batch_xy(batch)
            predicted = model(batch_x)
            validating_accuracy_list.append(calc_accuracy(batch_y, predicted))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 記錄最高的驗證集正確率與當時的模型狀態,判斷是否在 20 次訓練后仍然沒有重繪記錄
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 20:
            # 在 20 次訓練后仍然沒有重繪記錄,結束訓練
            print("stop training because highest validating accuracy not updated in 20 epoches")
            break

    # 使用達到最高正確率時的模型狀態
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 檢查測驗集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        batch_x, batch_y = split_batch_xy(batch)
        predicted = model(batch_x)
        testing_accuracy_list.append(calc_accuracy(batch_y, predicted))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 顯示訓練集和驗證集的正確率變化
    pyplot.plot(training_accuracy_history, label="training")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用訓練好的模型"""
    # 創建模型實體,加載訓練好的狀態,然后切換到驗證模式
    labels = load_image_labels()
    num_labels = len(labels)
    model = MyModel(num_labels)
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 詢問圖片路徑,并顯示可能的分類一覽
    while True:
        try:
            # 構建輸入
            image_path = input("Image path: ")
            if not image_path:
                continue
            with Image.open(image_path) as img:
                tensor_in = image_to_tensor(img).unsqueeze(0) # 維度 C,W,H => 1,C,W,H
            # 預測輸出
            tensor_out = model(tensor_in)
            # 轉換到各個分類對應的概率
            tensor_out = nn.functional.softmax(tensor_out, dim=1)
            # 顯示按概率排序后的分類一覽
            rates = (t.item() for t in tensor_out[0])
            label_with_rates = list(zip(labels, rates))
            label_with_rates.sort(key=lambda p:-p[1])
            for label, rate in label_with_rates[:5]:
                rate = rate * 100
                print(f"{label}: {rate:0.2f}%")
            print()
        except Exception as e:
            print("error:", e)

def main():
    """主函式"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 給亂數生成器分配一個初始值,使得每次運行都可以生成相同的亂數
    # 這是為了讓程序可重現,你也可以選擇不這樣做
    random.seed(0)
    torch.random.manual_seed(0)

    # 根據命令列引數選擇操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

準備訓練使用的資料和開始訓練需要分別執行以下命令:

python3 example.py prepare
python3 example.py train

最終輸出結果如下,可以看到訓練集正確率達到了 71%,驗證集和測驗集正確率達到了 61%,這個正確率代表可以精準說出圖片所屬的分類,也稱 top 1 正確率;此外計算正確分類在概率排前三的分類之中的比率稱為 top 3 正確率,如果是電商上傳圖片以后給出三個可能的商品分類讓商家選擇,那么計算 top 3 正確率就有意義了,

training accuracy: 0.7162083333333331
validating accuracy: 0.6134999999999998
stop training because highest validating accuracy not updated in 20 epoches
highest validating accuracy: 0.6183333333333333 from epoch 40
testing accuracy: 0.6168333333333332

訓練集與驗證集正確率變化如下圖所示:

實際使用模型的例子如下,輸出代表預測圖片有 79.23% 的概率是飛機,你也可以試試在互聯網上隨便找一張圖片讓這個模型識別:

$ python3 example.py eval
Image path: ./cifar/test/2257_airplane.png
airplane: 79.23%
deer: 6.06%
automobile: 4.04%
cat: 2.89%
frog: 2.11%

使用 CNN 實作圖片分類 (ResNet)

上述的模型 top 1 正確率只達到了 61%, 畢竟是 30 年前的老模型了??,這里我再介紹一個相對比較新的模型,ResNet 是在 2015 年中提出的模型,論文地址在這里,特征是會把輸入和輸出結合在一塊,例如原來計算 y = f(x) 會變為 y = f(x) + x,從而抵消層數變多帶來的梯度消失問題 (參考我之前寫的訓練程序中常用的技巧),

下圖是 ResNet-18 模型的結構,內部可以分為 4 組,每個組都包括 2 個基礎塊和 4 個卷積層,并且每個基礎塊會把輸入和輸出結合在一起,層數合計一共有 16,加上最開始轉換輸入的層和全連接層一共有 18 層,所以稱為 ResNet-18,除此之外還有 ResNet-34,ResNet-50 等等變種,如果有興趣可以參考本節末尾給出的 torchvision 的實作代碼,

從圖中可以看到,從第二組開始會把長寬變為一半,同時通道數增加一倍,然后維持通道數和長寬不變,所有組結束后使用一個 AvgPool2d 來讓長寬強制變為 1x1,最后交給全連接層,計算卷積層輸出長寬的公式是 (長度 - 內核大小 + 填充量*2) / 處理間隔 + 1,讓長寬變為一半會使用內核大小 3,填充量 1,處理間隔 2 ,例如長度為 32 可以計算得出 (32 - 3 + 2) / 2 + 1 == 16;而維持長寬的則會使用內核大小 3,填充量 1,處理間隔 1,例如長度為 32 可以計算得出 (32 - 3 + 2) / 1 + 1 == 32

以下是使用 ResNet-18 進行訓練的代碼:

import os
import sys
import torch
import gzip
import itertools
import random
import numpy
import json
from PIL import Image
from torch import nn
from matplotlib import pyplot

# 分析目標的圖片大小,全部圖片都會先縮放到這個大小
IMAGE_SIZE = (32, 32)
# 分析目標的圖片所在的檔案夾
IMAGE_DIR = "./cifar"
# 包含所有圖片標簽的文本檔案
IMAGE_LABELS_PATH = "./cifar/labels.txt"

class BasicBlock(nn.Module):
    """ResNet 使用的基礎塊"""
    expansion = 1 # 定義這個塊的實際出通道是 channels_out 的幾倍,這里的實作固定是一倍
    def __init__(self, channels_in, channels_out, stride):
        super().__init__()
        # 生成 3x3 的卷積層
        # 處理間隔 stride = 1 時,輸出的長寬會等于輸入的長寬,例如 (32-3+2)//1+1 == 32
        # 處理間隔 stride = 2 時,輸出的長寬會等于輸入的長寬的一半,例如 (32-3+2)//2+1 == 16
        # 此外 resnet 的 3x3 卷積層不使用偏移值 bias
        self.conv1 = nn.Sequential(
            nn.Conv2d(channels_in, channels_out, kernel_size=3, stride=stride, padding=1, bias=False),
            nn.BatchNorm2d(channels_out))
        # 再定義一個讓輸出和輸入維度相同的 3x3 卷積層
        self.conv2 = nn.Sequential(
            nn.Conv2d(channels_out, channels_out, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(channels_out))
        # 讓原始輸入和輸出相加的時候,需要維度一致,如果維度不一致則需要整合
        self.identity = nn.Sequential()
        if stride != 1 or channels_in != channels_out * self.expansion:
            self.identity = nn.Sequential(
                nn.Conv2d(channels_in, channels_out * self.expansion, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(channels_out * self.expansion))

    def forward(self, x):
        # x => conv1 => relu => conv2 => + => relu
        # |                              ^
        # |==============================|
        tmp = self.conv1(x)
        tmp = nn.functional.relu(tmp)
        tmp = self.conv2(tmp)
        tmp += self.identity(x)
        y = nn.functional.relu(tmp)
        return y

class MyModel(nn.Module):
    """圖片分類 (ResNet-18)"""
    def __init__(self, num_labels, block_type = BasicBlock):
        super().__init__()
        # 記錄上一層的出通道數量
        self.previous_channels_out = 64
        # 把 3 通道轉換到 64 通道,長寬不變
        self.conv1 = nn.Sequential(
            nn.Conv2d(3, self.previous_channels_out, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(self.previous_channels_out))
        # ResNet 使用的各個層
        self.layer1 = self._make_layer(block_type, channels_out=64, num_blocks=2, stride=1)
        self.layer2 = self._make_layer(block_type, channels_out=128, num_blocks=2, stride=2)
        self.layer3 = self._make_layer(block_type, channels_out=256, num_blocks=2, stride=2)
        self.layer4 = self._make_layer(block_type, channels_out=512, num_blocks=2, stride=2)
        # 把最后一層的長寬轉換為 1x1 的池化層,Adaptive 表示會自動檢測原有長寬
        # 例如 B,512,4,4 的矩陣會轉換為 B,512,1,1,每個通道的單個值會是原有 16 個值的平均
        self.avgPool = nn.AdaptiveAvgPool2d((1, 1))
        # 全連接層,只使用單層線性模型
        self.fc_model = nn.Linear(512 * block_type.expansion, num_labels)

    def _make_layer(self, block_type, channels_out, num_blocks, stride):
        blocks = []
        # 添加第一個塊
        blocks.append(block_type(self.previous_channels_out, channels_out, stride))
        self.previous_channels_out = channels_out * block_type.expansion
        # 添加剩余的塊,剩余的塊固定處理間隔為 1,不會改變長寬
        for _ in range(num_blocks-1):
            blocks.append(block_type(self.previous_channels_out, self.previous_channels_out, 1))
            self.previous_channels_out *= block_type.expansion
        return nn.Sequential(*blocks)

    def forward(self, x):
        # 轉換出通道到 64
        tmp = self.conv1(x)
        tmp = nn.functional.relu(tmp)
        # 應用 ResNet 的各個層
        tmp = self.layer1(tmp)
        tmp = self.layer2(tmp)
        tmp = self.layer3(tmp)
        tmp = self.layer4(tmp)
        # 轉換長寬到 1x1
        tmp = self.avgPool(tmp)
        # 扁平化,維度會變為 B,512
        tmp = tmp.view(tmp.shape[0], -1)
        # 應用全連接層
        y = self.fc_model(tmp)
        return y

def save_tensor(tensor, path):
    """保存 tensor 物件到檔案"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """從檔案讀取 tensor 物件"""
    return torch.load(gzip.GzipFile(path, "rb"))

def image_to_tensor(img):
    """轉換圖片物件到 tensor 物件"""
    in_img = img.resize(IMAGE_SIZE)
    arr = numpy.asarray(in_img)
    t = torch.from_numpy(arr)
    t = t.transpose(0, 2) # 轉換維度 H,W,C 到 C,W,H
    t = t / 255.0 # 正規化數值使得范圍在 0 ~ 1
    return t

def load_image_labels():
    """讀取圖片分類串列"""
    return list(filter(None, open(IMAGE_LABELS_PATH).read().split()))

def prepare_save_batch(batch, tensor_in, tensor_out):
    """準備訓練 - 保存單個批次的資料"""
    # 切分訓練集 (80%),驗證集 (10%) 和測驗集 (10%)
    random_indices = torch.randperm(tensor_in.shape[0])
    training_indices = random_indices[:int(len(random_indices)*0.8)]
    validating_indices = random_indices[int(len(random_indices)*0.8):int(len(random_indices)*0.9):]
    testing_indices = random_indices[int(len(random_indices)*0.9):]
    training_set = (tensor_in[training_indices], tensor_out[training_indices])
    validating_set = (tensor_in[validating_indices], tensor_out[validating_indices])
    testing_set = (tensor_in[testing_indices], tensor_out[testing_indices])

    # 保存到硬碟
    save_tensor(training_set, f"data/training_set.{batch}.pt")
    save_tensor(validating_set, f"data/validating_set.{batch}.pt")
    save_tensor(testing_set, f"data/testing_set.{batch}.pt")
    print(f"batch {batch} saved")

def prepare():
    """準備訓練"""
    # 資料集轉換到 tensor 以后會保存在 data 檔案夾下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 準備圖片分類到序號的索引
    labels_to_index = { label: index for index, label in enumerate(load_image_labels()) }

    # 查找所有圖片
    image_paths = []
    for root, dirs, files in os.walk(IMAGE_DIR):
        for filename in files:
            path = os.path.join(root, filename)
            if not path.endswith(".png"):
                continue
            # 分類名稱在檔案名中,例如
            # 2598_cat.png => cat
            label = filename.split(".")[0].split("_")[1]
            label_index = labels_to_index.get(label)
            if label_index is None:
                continue
            image_paths.append((path, label_index))

    # 打亂圖片順序
    random.shuffle(image_paths)

    # 分批讀取和保存圖片
    batch_size = 1000
    for batch in range(0, len(image_paths) // batch_size):
        image_tensors = []
        image_labels = []
        for path, label_index in image_paths[batch*batch_size:(batch+1)*batch_size]:
            with Image.open(path) as img:
                t = image_to_tensor(img)
                image_tensors.append(t)
            image_labels.append(label_index)
        tensor_in = torch.stack(image_tensors) # 維度: B,C,W,H
        tensor_out = torch.tensor(image_labels) # 維度: B
        prepare_save_batch(batch, tensor_in, tensor_out)

def train():
    """開始訓練"""
    # 創建模型實體
    num_labels = len(load_image_labels())
    model = MyModel(num_labels)

    # 創建損失計算器
    # 計算單分類輸出最好使用 CrossEntropyLoss, 多分類輸出最好使用 BCELoss
    # 使用 CrossEntropyLoss 時實際輸出應該為標簽索引值,不需要轉換為 onehot
    loss_function = torch.nn.CrossEntropyLoss()

    # 創建引數調整器
    optimizer = torch.optim.Adam(model.parameters())

    # 記錄訓練集和驗證集的正確率變化
    training_accuracy_history = []
    validating_accuracy_history = []

    # 記錄最高的驗證集正確率
    validating_accuracy_highest = -1
    validating_accuracy_highest_epoch = 0

    # 讀取批次的工具函式
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield load_tensor(path)

    # 計算正確率的工具函式
    def calc_accuracy(actual, predicted):
        # 把最大的值當作正確分類,然后比對有多少個分類相等
        predicted_labels = predicted.argmax(dim=1)
        acc = (actual == predicted_labels).sum().item() / actual.shape[0]
        return acc

    # 劃分輸入和輸出的工具函式
    def split_batch_xy(batch, begin=None, end=None):
        # shape = batch_size, channels, width, height
        batch_x = batch[0][begin:end]
        # shape = batch_size
        batch_y = batch[1][begin:end]
        return batch_x, batch_y

    # 開始訓練程序
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根據訓練集訓練并修改引數
        # 切換模型到訓練模式,將會啟用自動微分,批次正規化 (BatchNorm) 與 Dropout
        model.train()
        training_accuracy_list = []
        for batch_index, batch in enumerate(read_batches("data/training_set")):
            # 切分小批次,有助于泛化模型
            training_batch_accuracy_list = []
            for index in range(0, batch[0].shape[0], 100):
                # 劃分輸入和輸出
                batch_x, batch_y = split_batch_xy(batch, index, index+100)
                # 計算預測值
                predicted = model(batch_x)
                # 計算損失
                loss = loss_function(predicted, batch_y)
                # 從損失自動微分求導函式值
                loss.backward()
                # 使用引數調整器調整引數
                optimizer.step()
                # 清空導函式值
                optimizer.zero_grad()
                # 記錄這一個批次的正確率,torch.no_grad 代表臨時禁用自動微分功能
                with torch.no_grad():
                    training_batch_accuracy_list.append(calc_accuracy(batch_y, predicted))
            # 輸出批次正確率
            training_batch_accuracy = sum(training_batch_accuracy_list) / len(training_batch_accuracy_list)
            training_accuracy_list.append(training_batch_accuracy)
            print(f"epoch: {epoch}, batch: {batch_index}: batch accuracy: {training_batch_accuracy}")
        training_accuracy = sum(training_accuracy_list) / len(training_accuracy_list)
        training_accuracy_history.append(training_accuracy)
        print(f"training accuracy: {training_accuracy}")

        # 檢查驗證集
        # 切換模型到驗證模式,將會禁用自動微分,批次正規化 (BatchNorm) 與 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_y = split_batch_xy(batch)
            predicted = model(batch_x)
            validating_accuracy_list.append(calc_accuracy(batch_y, predicted))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 記錄最高的驗證集正確率與當時的模型狀態,判斷是否在 20 次訓練后仍然沒有重繪記錄
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 20:
            # 在 20 次訓練后仍然沒有重繪記錄,結束訓練
            print("stop training because highest validating accuracy not updated in 20 epoches")
            break

    # 使用達到最高正確率時的模型狀態
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 檢查測驗集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        batch_x, batch_y = split_batch_xy(batch)
        predicted = model(batch_x)
        testing_accuracy_list.append(calc_accuracy(batch_y, predicted))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 顯示訓練集和驗證集的正確率變化
    pyplot.plot(training_accuracy_history, label="training")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用訓練好的模型"""
    # 創建模型實體,加載訓練好的狀態,然后切換到驗證模式
    labels = load_image_labels()
    num_labels = len(labels)
    model = MyModel(num_labels)
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 詢問圖片路徑,并顯示可能的分類一覽
    while True:
        try:
            # 構建輸入
            image_path = input("Image path: ")
            if not image_path:
                continue
            with Image.open(image_path) as img:
                tensor_in = image_to_tensor(img).unsqueeze(0) # 維度 C,W,H => 1,C,W,H
            # 預測輸出
            tensor_out = model(tensor_in)
            # 轉換到各個分類對應的概率
            tensor_out = nn.functional.softmax(tensor_out, dim=1)
            # 顯示按概率排序后的分類一覽
            rates = (t.item() for t in tensor_out[0])
            label_with_rates = list(zip(labels, rates))
            label_with_rates.sort(key=lambda p:-p[1])
            for label, rate in label_with_rates[:5]:
                rate = rate * 100
                print(f"{label}: {rate:0.2f}%")
            print()
        except Exception as e:
            print("error:", e)

def main():
    """主函式"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 給亂數生成器分配一個初始值,使得每次運行都可以生成相同的亂數
    # 這是為了讓程序可重現,你也可以選擇不這樣做
    random.seed(0)
    torch.random.manual_seed(0)

    # 根據命令列引數選擇操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

最終輸出結果如下,可以看到訓練集正確率達到了 99%,驗證集正確率達到了 85%,測驗集正確率達到了 84%,比起上面的 LeNet 模型改進了很多吧??,

training accuracy: 0.9972708333333337
validating accuracy: 0.8373333333333337
stop training because highest validating accuracy not updated in 20 epoches
highest validating accuracy: 0.8521666666666667 from epoch 38
testing accuracy: 0.8464999999999996

隨便在網上找的貓狗圖片:

輸出結果如下,不錯吧:

Image path: BlogArchive/ml-08/cat.jpg
cat: 100.00%
dog: 0.00%
frog: 0.00%
deer: 0.00%
horse: 0.00%

Image path: BlogArchive/ml-08/dog.jpg
dog: 100.00%
bird: 0.00%
deer: 0.00%
frog: 0.00%
horse: 0.00%

pytorch 有專門用于處理視覺資訊的 torchvision,其中包含了 ResNet 的實作,也就是說其實我們不用自己去寫??,如果你有興趣可以參考里面的實作代碼,再試試 ResNet-50 等層數更多的模型是否可以帶來更好的效果,

AI 鑒黃

相信很多人都看過 AI 鑒黃的新聞??????,如果你想自己實作一個,可以從 nsfw_data_scraper 下載圖片資源然后使用上面介紹的方法訓練,識別起來會比 cifar 簡單很多,因為實際只需要兩個標簽(1 黃色圖片,0 正常圖片),所以也可以使用單個值代表結果,然后用 sigmoid 代替 softmax,此外你也可以在 github 上搜索 nsfw 找到現成的模型,

使用 CNN 實作驗證碼識別 (ResNet-18)

最后再給出一個實用的例子,很多網站為了防機器人操作會使用驗證碼機制,傳統的驗證碼會顯示一張包含數字字母的圖片,然后讓用戶填寫里面的內容再對比是否正確,來判斷用戶是普通人還是機器人,這樣的驗證碼可以用本篇介紹的 CNN 模型識別出來??,

首先我們來選一個生成驗證碼的類別庫,github 上搜索 captcha c# 里面難度相對比較高的是 Hei.Captcha,這篇就使用 CNN 模型識別這個類別庫生成的驗證碼,(我的 zkweb 里面也有生成驗證碼的模塊,但難度比較低所以就不用了)

以下步驟和代碼會生成十萬張用于訓練和測驗使用的驗證碼圖片:

mkdir generate-captcha
cd generate-captcha
dotnet new console
dotnet add package Hei.Captcha
mkdir output
mkdir fonts
cd fonts
wget https://github.com/gebiWangshushu/Hei.Captcha/blob/master/Demo/fonts/Candara.ttf?raw=true
wget https://github.com/gebiWangshushu/Hei.Captcha/blob/master/Demo/fonts/STCAIYUN.ttf?raw=true
wget https://github.com/gebiWangshushu/Hei.Captcha/blob/master/Demo/fonts/impact.ttf?raw=true
wget https://github.com/gebiWangshushu/Hei.Captcha/blob/master/Demo/fonts/monbaiti.ttf?raw=true
cd ..
# 添加程式代碼
dotnet run -c Release
using System;
using System.IO;
using Hei.Captcha;

namespace generate_captcha
{
    class Program
    {
        static void Main(string[] args)
        {
            var helper = new SecurityCodeHelper();
            var iterations = 100000;
            for (var x = 0; x < iterations; ++x)
            {
                var code = helper.GetRandomEnDigitalText(4);
                var bytes = helper.GetEnDigitalCodeByte(code);
                File.WriteAllBytes($"output/{x:D5}-{code}.png", bytes);
                if (x % 100 == 0)
                    Console.WriteLine($"{x}/{iterations}");
            }
        }
    }
}

以下是生成的驗證碼圖片例子,變形旋轉干擾線動態背景色該有的都有??:

接下來我們想想應該用什么資料結構來表達驗證碼,在圖片識別的例子中有十個分類,我們用了 onehot 編碼,即使用長度為 10 的 tensor 物件來表示結果,正確的分類為 1,不正確的分類為 0,換成驗證碼以后,可以用長度為 36 的 tensor 物件來表示 1 位驗證碼 (26 個英文數字 + 10 個字母,假設驗證碼不分大小寫),如果有多位則可以 36 * 位數的 tensor 物件來表達多位驗證碼,以下函式可以把驗證碼轉換為對應的 tensor 物件:

# 字母數字串列
ALPHA_NUMS = "abcdefghijklmnopqrstuvwxyz0123456789"
ALPHA_NUMS_MAP = { c: index for index, c in enumerate(ALPHA_NUMS) }
# 驗證碼位數
DIGITS = 4
# 標簽數量,字母數字混合*位數
NUM_LABELS = len(ALPHA_NUMS)*DIGITS

def code_to_tensor(code):
    """轉換驗證碼到 tensor 物件,使用 onehot 編碼"""
    t = torch.zeros((NUM_LABELS,))
    code = code.lower() # 驗證碼不分大小寫
    for index, c in enumerate(code):
        p = ALPHA_NUMS_MAP[c]
        t[index*len(ALPHA_NUMS)+p] = 1
    return t

轉換例子如下:

>>> code_to_tensor("abcd")
tensor([1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
>>> code_to_tensor("a123")
tensor([1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0.])

反過來也一樣,我們可以把 tensor 的長度按 36 分為多組,然后求每一組最大的值所在的索引,再根據該索引找到對應的字母或者數字,就可以把 tensor 物件轉換回驗證碼:

def tensor_to_code(tensor):
    """轉換 tensor 物件到驗證碼"""
    tensor = tensor.reshape(DIGITS, len(ALPHA_NUMS))
    indices = tensor.max(dim=1).indices
    code = "".join(ALPHA_NUMS[index] for index in indices)
    return code

接下來就可以用前面介紹過的 ResNet-18 模型進行訓練了??,相比前面的圖片分類,這份代碼有以下幾點不同:

  • 因為是多分類,損失計算器應該使用 BCELoss 代替 CrossEntropyLoss
  • BCELoss 要求模型輸出值范圍在 0 ~ 1 之間,所以需要在模型內部添加控制函式 (CrossEntropyLoss 這么做會影響訓練效果,但 BCELoss 不會)
  • 因為每一組都只有一個值是正確的,用 softmax 效果會比 sigmoid 要好 (普通的多分類問題會使用 sigmoid)
import os
import sys
import torch
import gzip
import itertools
import random
import numpy
import json
from PIL import Image
from torch import nn
from matplotlib import pyplot

# 分析目標的圖片大小,全部圖片都會先縮放到這個大小
# 驗證碼原圖是 120x50
IMAGE_SIZE = (56, 24)
# 分析目標的圖片所在的檔案夾
IMAGE_DIR = "./generate-captcha/output/"
# 字母數字串列
ALPHA_NUMS = "abcdefghijklmnopqrstuvwxyz0123456789"
ALPHA_NUMS_MAP = { c: index for index, c in enumerate(ALPHA_NUMS) }
# 驗證碼位數
DIGITS = 4
# 標簽數量,字母數字混合*位數
NUM_LABELS = len(ALPHA_NUMS)*DIGITS

class BasicBlock(nn.Module):
    """ResNet 使用的基礎塊"""
    expansion = 1 # 定義這個塊的實際出通道是 channels_out 的幾倍,這里的實作固定是一倍
    def __init__(self, channels_in, channels_out, stride):
        super().__init__()
        # 生成 3x3 的卷積層
        # 處理間隔 stride = 1 時,輸出的長寬會等于輸入的長寬,例如 (32-3+2)//1+1 == 32
        # 處理間隔 stride = 2 時,輸出的長寬會等于輸入的長寬的一半,例如 (32-3+2)//2+1 == 16
        # 此外 resnet 的 3x3 卷積層不使用偏移值 bias
        self.conv1 = nn.Sequential(
            nn.Conv2d(channels_in, channels_out, kernel_size=3, stride=stride, padding=1, bias=False),
            nn.BatchNorm2d(channels_out))
        # 再定義一個讓輸出和輸入維度相同的 3x3 卷積層
        self.conv2 = nn.Sequential(
            nn.Conv2d(channels_out, channels_out, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(channels_out))
        # 讓原始輸入和輸出相加的時候,需要維度一致,如果維度不一致則需要整合
        self.identity = nn.Sequential()
        if stride != 1 or channels_in != channels_out * self.expansion:
            self.identity = nn.Sequential(
                nn.Conv2d(channels_in, channels_out * self.expansion, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(channels_out * self.expansion))

    def forward(self, x):
        # x => conv1 => relu => conv2 => + => relu
        # |                              ^
        # |==============================|
        tmp = self.conv1(x)
        tmp = nn.functional.relu(tmp)
        tmp = self.conv2(tmp)
        tmp += self.identity(x)
        y = nn.functional.relu(tmp)
        return y

class MyModel(nn.Module):
    """識別驗證碼 (ResNet-18)"""
    def __init__(self, block_type = BasicBlock):
        super().__init__()
        # 記錄上一層的出通道數量
        self.previous_channels_out = 64
        # 把 3 通道轉換到 64 通道,長寬不變
        self.conv1 = nn.Sequential(
            nn.Conv2d(3, self.previous_channels_out, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(self.previous_channels_out))
        # ResNet 使用的各個層
        self.layer1 = self._make_layer(block_type, channels_out=64, num_blocks=2, stride=1)
        self.layer2 = self._make_layer(block_type, channels_out=128, num_blocks=2, stride=2)
        self.layer3 = self._make_layer(block_type, channels_out=256, num_blocks=2, stride=2)
        self.layer4 = self._make_layer(block_type, channels_out=512, num_blocks=2, stride=2)
        # 把最后一層的長寬轉換為 1x1 的池化層,Adaptive 表示會自動檢測原有長寬
        # 例如 B,512,4,4 的矩陣會轉換為 B,512,1,1,每個通道的單個值會是原有 16 個值的平均
        self.avgPool = nn.AdaptiveAvgPool2d((1, 1))
        # 全連接層,只使用單層線性模型
        self.fc_model = nn.Linear(512 * block_type.expansion, NUM_LABELS)
        # 控制輸出在 0 ~ 1 之間,BCELoss 需要
        # 因為每組只應該有一個值為真,使用 softmax 效果會比 sigmoid 好
        self.softmax = nn.Softmax(dim=2)

    def _make_layer(self, block_type, channels_out, num_blocks, stride):
        blocks = []
        # 添加第一個塊
        blocks.append(block_type(self.previous_channels_out, channels_out, stride))
        self.previous_channels_out = channels_out * block_type.expansion
        # 添加剩余的塊,剩余的塊固定處理間隔為 1,不會改變長寬
        for _ in range(num_blocks-1):
            blocks.append(block_type(self.previous_channels_out, self.previous_channels_out, 1))
            self.previous_channels_out *= block_type.expansion
        return nn.Sequential(*blocks)

    def forward(self, x):
        # 轉換出通道到 64
        tmp = self.conv1(x)
        tmp = nn.functional.relu(tmp)
        # 應用 ResNet 的各個層
        tmp = self.layer1(tmp)
        tmp = self.layer2(tmp)
        tmp = self.layer3(tmp)
        tmp = self.layer4(tmp)
        # 轉換長寬到 1x1
        tmp = self.avgPool(tmp)
        # 扁平化,維度會變為 B,512
        tmp = tmp.view(tmp.shape[0], -1)
        # 應用全連接層
        tmp = self.fc_model(tmp)
        # 劃分每個字符對應的組,之后維度為 batch_size, digits, alpha_nums
        tmp = tmp.reshape(tmp.shape[0], DIGITS, len(ALPHA_NUMS))
        # 應用 softmax 到每一組
        tmp = self.softmax(tmp)
        # 重新扁平化,之后維度為 batch_size, num_labels
        y = tmp.reshape(tmp.shape[0], NUM_LABELS)
        return y

def save_tensor(tensor, path):
    """保存 tensor 物件到檔案"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """從檔案讀取 tensor 物件"""
    return torch.load(gzip.GzipFile(path, "rb"))

def image_to_tensor(img):
    """轉換圖片物件到 tensor 物件"""
    in_img = img.resize(IMAGE_SIZE)
    in_img = in_img.convert("RGB") # 轉換圖片模式到 RGB
    arr = numpy.asarray(in_img)
    t = torch.from_numpy(arr)
    t = t.transpose(0, 2) # 轉換維度 H,W,C 到 C,W,H
    t = t / 255.0 # 正規化數值使得范圍在 0 ~ 1
    return t

def code_to_tensor(code):
    """轉換驗證碼到 tensor 物件,使用 onehot 編碼"""
    t = torch.zeros((NUM_LABELS,))
    code = code.lower() # 驗證碼不分大小寫
    for index, c in enumerate(code):
        p = ALPHA_NUMS_MAP[c]
        t[index*len(ALPHA_NUMS)+p] = 1
    return t

def tensor_to_code(tensor):
    """轉換 tensor 物件到驗證碼"""
    tensor = tensor.reshape(DIGITS, len(ALPHA_NUMS))
    indices = tensor.max(dim=1).indices
    code = "".join(ALPHA_NUMS[index] for index in indices)
    return code

def prepare_save_batch(batch, tensor_in, tensor_out):
    """準備訓練 - 保存單個批次的資料"""
    # 切分訓練集 (80%),驗證集 (10%) 和測驗集 (10%)
    random_indices = torch.randperm(tensor_in.shape[0])
    training_indices = random_indices[:int(len(random_indices)*0.8)]
    validating_indices = random_indices[int(len(random_indices)*0.8):int(len(random_indices)*0.9):]
    testing_indices = random_indices[int(len(random_indices)*0.9):]
    training_set = (tensor_in[training_indices], tensor_out[training_indices])
    validating_set = (tensor_in[validating_indices], tensor_out[validating_indices])
    testing_set = (tensor_in[testing_indices], tensor_out[testing_indices])

    # 保存到硬碟
    save_tensor(training_set, f"data/training_set.{batch}.pt")
    save_tensor(validating_set, f"data/validating_set.{batch}.pt")
    save_tensor(testing_set, f"data/testing_set.{batch}.pt")
    print(f"batch {batch} saved")

def prepare():
    """準備訓練"""
    # 資料集轉換到 tensor 以后會保存在 data 檔案夾下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 查找所有圖片
    image_paths = []
    for root, dirs, files in os.walk(IMAGE_DIR):
        for filename in files:
            path = os.path.join(root, filename)
            if not path.endswith(".png"):
                continue
            # 驗證碼在檔案名中,例如
            # 00000-R865.png => R865
            code = filename.split(".")[0].split("-")[1]
            image_paths.append((path, code))

    # 打亂圖片順序
    random.shuffle(image_paths)

    # 分批讀取和保存圖片
    batch_size = 1000
    for batch in range(0, len(image_paths) // batch_size):
        image_tensors = []
        image_labels = []
        for path, code in image_paths[batch*batch_size:(batch+1)*batch_size]:
            with Image.open(path) as img:
                image_tensors.append(image_to_tensor(img))
            image_labels.append(code_to_tensor(code))
        tensor_in = torch.stack(image_tensors) # 維度: B,C,W,H
        tensor_out = torch.stack(image_labels) # 維度: B,N
        prepare_save_batch(batch, tensor_in, tensor_out)

def train():
    """開始訓練"""
    # 創建模型實體
    model = MyModel()

    # 創建損失計算器
    # 計算多分類輸出最好使用 BCELoss
    loss_function = torch.nn.BCELoss()

    # 創建引數調整器
    optimizer = torch.optim.Adam(model.parameters())

    # 記錄訓練集和驗證集的正確率變化
    training_accuracy_history = []
    validating_accuracy_history = []

    # 記錄最高的驗證集正確率
    validating_accuracy_highest = -1
    validating_accuracy_highest_epoch = 0

    # 讀取批次的工具函式
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield load_tensor(path)

    # 計算正確率的工具函式
    def calc_accuracy(actual, predicted):
        # 把每一位的最大值當作正確字符,然后比對有多少個字符相等
        actual_indices = actual.reshape(actual.shape[0], DIGITS, len(ALPHA_NUMS)).max(dim=2).indices
        predicted_indices = predicted.reshape(predicted.shape[0], DIGITS, len(ALPHA_NUMS)).max(dim=2).indices
        matched = (actual_indices - predicted_indices).abs().sum(dim=1) == 0
        acc = matched.sum().item() / actual.shape[0]
        return acc
 
    # 劃分輸入和輸出的工具函式
    def split_batch_xy(batch, begin=None, end=None):
        # shape = batch_size, channels, width, height
        batch_x = batch[0][begin:end]
        # shape = batch_size, num_labels
        batch_y = batch[1][begin:end]
        return batch_x, batch_y

    # 開始訓練程序
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根據訓練集訓練并修改引數
        # 切換模型到訓練模式,將會啟用自動微分,批次正規化 (BatchNorm) 與 Dropout
        model.train()
        training_accuracy_list = []
        for batch_index, batch in enumerate(read_batches("data/training_set")):
            # 切分小批次,有助于泛化模型
            training_batch_accuracy_list = []
            for index in range(0, batch[0].shape[0], 100):
                # 劃分輸入和輸出
                batch_x, batch_y = split_batch_xy(batch, index, index+100)
                # 計算預測值
                predicted = model(batch_x)
                # 計算損失
                loss = loss_function(predicted, batch_y)
                # 從損失自動微分求導函式值
                loss.backward()
                # 使用引數調整器調整引數
                optimizer.step()
                # 清空導函式值
                optimizer.zero_grad()
                # 記錄這一個批次的正確率,torch.no_grad 代表臨時禁用自動微分功能
                with torch.no_grad():
                    training_batch_accuracy_list.append(calc_accuracy(batch_y, predicted))
            # 輸出批次正確率
            training_batch_accuracy = sum(training_batch_accuracy_list) / len(training_batch_accuracy_list)
            training_accuracy_list.append(training_batch_accuracy)
            print(f"epoch: {epoch}, batch: {batch_index}: batch accuracy: {training_batch_accuracy}")
        training_accuracy = sum(training_accuracy_list) / len(training_accuracy_list)
        training_accuracy_history.append(training_accuracy)
        print(f"training accuracy: {training_accuracy}")

        # 檢查驗證集
        # 切換模型到驗證模式,將會禁用自動微分,批次正規化 (BatchNorm) 與 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_y = split_batch_xy(batch)
            predicted = model(batch_x)
            validating_accuracy_list.append(calc_accuracy(batch_y, predicted))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 記錄最高的驗證集正確率與當時的模型狀態,判斷是否在 20 次訓練后仍然沒有重繪記錄
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 20:
            # 在 20 次訓練后仍然沒有重繪記錄,結束訓練
            print("stop training because highest validating accuracy not updated in 20 epoches")
            break

    # 使用達到最高正確率時的模型狀態
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 檢查測驗集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        batch_x, batch_y = split_batch_xy(batch)
        predicted = model(batch_x)
        testing_accuracy_list.append(calc_accuracy(batch_y, predicted))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 顯示訓練集和驗證集的正確率變化
    pyplot.plot(training_accuracy_history, label="training")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用訓練好的模型"""
    # 創建模型實體,加載訓練好的狀態,然后切換到驗證模式
    model = MyModel()
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 詢問圖片路徑,并顯示可能的分類一覽
    while True:
        try:
            # 構建輸入
            image_path = input("Image path: ")
            if not image_path:
                continue
            with Image.open(image_path) as img:
                tensor_in = image_to_tensor(img).unsqueeze(0) # 維度 C,W,H => 1,C,W,H
            # 預測輸出
            tensor_out = model(tensor_in)
            # 轉換到驗證碼
            code = tensor_to_code(tensor_out[0])
            print(f"code: {code}")
            print()
        except Exception as e:
            print("error:", e)

def main():
    """主函式"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 給亂數生成器分配一個初始值,使得每次運行都可以生成相同的亂數
    # 這是為了讓程序可重現,你也可以選擇不這樣做
    random.seed(0)
    torch.random.manual_seed(0)

    # 根據命令列引數選擇操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

因為訓練需要大量時間而我機器只有 CPU 可以用,所以這次我就只訓練到 epoch 23 ??,訓練結果如下,可以看到訓練集正確率達到了 98%,驗證集正確率達到了 91%,已經是實用的級別了,

epoch: 23, batch: 98: batch accuracy: 0.99125
epoch: 23, batch: 99: batch accuracy: 0.9862500000000001
training accuracy: 0.9849874999999997
validating accuracy: 0.9103000000000003
highest validating accuracy updated

使用訓練好的模型識別驗證碼,你可以對比上面的圖片看看是不是識別對了 (第二張的 P 看起來很像 D ??):

$ python3 example.py eval
Image path: BlogArchive/ml-08/captcha-1.png
code: 8ca6

Image path: BlogArchive/ml-08/captcha-2.png
code: tp8s

Image path: BlogArchive/ml-08/captcha-3.png
code: k225

注意這里介紹出來的模型只能識別這一種驗證碼,其他不同種類的驗證碼需要分別訓練和生成模型,做打碼平臺的話會先識別驗證碼種類再使用該種類對應的模型識別驗證碼內容,如果你的目標只是單種驗證碼,那么用這篇文章介紹的方法應該可以幫你節省調打碼平臺的錢 ??,如果你機器有好顯卡,也可以試試用更高級的模型提升正確率,

此外,有很多人問我現在流行的滑動驗證碼如何破解,其實破解這種驗證碼只需要做簡單的圖片分析,例如這里和這里都沒有使用機器學習,但滑動驗證碼一般會配合瀏覽器指紋和滑鼠軌跡采集一起使用,后臺會根據大量資料分析用戶是普通人還是機器人,所以破解幾次很簡單,但一直破解下去則會有很大幾率被檢測出來,

使用 torchvision 里面的 resnet 模型

在前文我們看到了怎么組合卷積層和池化層自己實作 LeNet 和 ResNet-18,我們還可以使用 torchvision 中現成的模型,以下是修改識別驗證碼的模型到 torchvision 提供的 ResNet 實作的代碼:

# 檔案開頭參考 torchvision 庫
import torchvision

# 替換原有代碼中的 MyModel 類,BasicBlock 可以刪掉
class MyModel(nn.Module):
    """識別驗證碼 (ResNet-18)"""
    def __init__(self):
        super().__init__()
        # Resnet 的實作
        self.resnet = torchvision.models.resnet18(num_classes=NUM_LABELS)
        # 控制輸出在 0 ~ 1 之間,BCELoss 需要
        # 因為每組只應該有一個值為真,使用 softmax 效果會比 sigmoid 好
        self.softmax = nn.Softmax(dim=2)

    def forward(self, x):
        # 應用 ResNet
        tmp = self.resnet(x)
        # 劃分每個字符對應的組,之后維度為 batch_size, digits, alpha_nums
        tmp = tmp.reshape(tmp.shape[0], DIGITS, len(ALPHA_NUMS))
        # 應用 softmax 到每一組
        tmp = self.softmax(tmp)
        # 重新扁平化,之后維度為 batch_size, num_labels
        y = tmp.reshape(tmp.shape[0], NUM_LABELS)
        return y

是不是簡單了很多?如果我們想使用 ResNet-50 可以把 resnet18 改為 resnet50 即可切換,雖然使用現成的模型方便,但了解下它們的原理和計算方式總是有好處的??,

寫在最后

這個系列中預定要寫的內容已經全部寫出來了,接下來要寫什么還不確定,有時間可能會重新維護那些放了半年以上的專案,也可能會想辦法搞好飯店的生意,最近生意實在不好啊??,

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/3929.html

標籤:其他

上一篇:強化學習中的線性代數知識

下一篇:用Spark-NLP建立文本分類模型

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 網閘典型架構簡述

    網閘架構一般分為兩種:三主機的三系統架構網閘和雙主機的2+1架構網閘。 三主機架構分別為內端機、外端機和仲裁機。三機無論從軟體和硬體上均各自獨立。首先從硬體上來看,三機都用各自獨立的主板、記憶體及存盤設備。從軟體上來看,三機有各自獨立的作業系統。這樣能達到完全的三機獨立。對于“2+1”系統,“2”分為 ......

    uj5u.com 2020-09-10 02:00:44 more
  • 如何從xshell上傳檔案到centos linux虛擬機里

    如何從xshell上傳檔案到centos linux虛擬機里及:虛擬機CentOs下執行 yum -y install lrzsz命令,出現錯誤:鏡像無法找到軟體包 前言 一、安裝lrzsz步驟 二、上傳檔案 三、遇到的問題及解決方案 總結 前言 提示:其實很簡單,往虛擬機上安裝一個上傳檔案的工具 ......

    uj5u.com 2020-09-10 02:00:47 more
  • 一、SQLMAP入門

    一、SQLMAP入門 1、判斷是否存在注入 sqlmap.py -u 網址/id=1 id=1不可缺少。當注入點后面的引數大于兩個時。需要加雙引號, sqlmap.py -u "網址/id=1&uid=1" 2、判斷文本中的請求是否存在注入 從文本中加載http請求,SQLMAP可以從一個文本檔案中 ......

    uj5u.com 2020-09-10 02:00:50 more
  • Metasploit 簡單使用教程

    metasploit 簡單使用教程 浩先生, 2020-08-28 16:18:25 分類專欄: kail 網路安全 linux 文章標簽: linux資訊安全 編輯 著作權 metasploit 使用教程 前言 一、Metasploit是什么? 二、準備作業 三、具體步驟 前言 Msfconsole ......

    uj5u.com 2020-09-10 02:00:53 more
  • 游戲逆向之驅動層與用戶層通訊

    驅動層代碼: #pragma once #include <ntifs.h> #define add_code CTL_CODE(FILE_DEVICE_UNKNOWN,0x800,METHOD_BUFFERED,FILE_ANY_ACCESS) /* 更多游戲逆向視頻www.yxfzedu.com ......

    uj5u.com 2020-09-10 02:00:56 more
  • 北斗電力時鐘(北斗授時服務器)讓網路資料更精準

    北斗電力時鐘(北斗授時服務器)讓網路資料更精準 北斗電力時鐘(北斗授時服務器)讓網路資料更精準 京準電子科技官微——ahjzsz 近幾年,資訊技術的得了快速發展,互聯網在逐漸普及,其在人們生活和生產中都得到了廣泛應用,并且取得了不錯的應用效果。計算機網路資訊在電力系統中的應用,一方面使電力系統的運行 ......

    uj5u.com 2020-09-10 02:01:03 more
  • 【CTF】CTFHub 技能樹 彩蛋 writeup

    ?碎碎念 CTFHub:https://www.ctfhub.com/ 筆者入門CTF時時剛開始刷的是bugku的舊平臺,后來才有了CTFHub。 感覺不論是網頁UI設計,還是題目質量,賽事跟蹤,工具軟體都做得很不錯。 而且因為獨到的金幣制度的確讓人有一種想去刷題賺金幣的感覺。 個人還是非常喜歡這個 ......

    uj5u.com 2020-09-10 02:04:05 more
  • 02windows基礎操作

    我學到了一下幾點 Windows系統目錄結構與滲透的作用 常見Windows的服務詳解 Windows埠詳解 常用的Windows注冊表詳解 hacker DOS命令詳解(net user / type /md /rd/ dir /cd /net use copy、批處理 等) 利用dos命令制作 ......

    uj5u.com 2020-09-10 02:04:18 more
  • 03.Linux基礎操作

    我學到了以下幾點 01Linux系統介紹02系統安裝,密碼啊破解03Linux常用命令04LAMP 01LINUX windows: win03 8 12 16 19 配置不繁瑣 Linux:redhat,centos(紅帽社區版),Ubuntu server,suse unix:金融機構,證券,銀 ......

    uj5u.com 2020-09-10 02:04:30 more
  • 05HTML

    01HTML介紹 02頭部標簽講解03基礎標簽講解04表單標簽講解 HTML前段語言 js1.了解代碼2.根據代碼 懂得挖掘漏洞 (POST注入/XSS漏洞上傳)3.黑帽seo 白帽seo 客戶網站被黑帽植入劫持代碼如何處理4.熟悉html表單 <html><head><title>TDK標題,描述 ......

    uj5u.com 2020-09-10 02:04:36 more
最新发布
  • 2023年最新微信小程式抓包教程

    01 開門見山 隔一個月發一篇文章,不過分。 首先回顧一下《微信系結手機號資料庫被脫庫事件》,我也是第一時間得知了這個訊息,然后跟蹤了整件事情的經過。下面是這起事件的相關截圖以及近日流出的一萬條資料樣本: 個人認為這件事也沒什么,還不如關注一下之前45億快遞資料查詢渠道疑似在近日復活的訊息。 訊息是 ......

    uj5u.com 2023-04-20 08:48:24 more
  • web3 產品介紹:metamask 錢包 使用最多的瀏覽器插件錢包

    Metamask錢包是一種基于區塊鏈技術的數字貨幣錢包,它允許用戶在安全、便捷的環境下管理自己的加密資產。Metamask錢包是以太坊生態系統中最流行的錢包之一,它具有易于使用、安全性高和功能強大等優點。 本文將詳細介紹Metamask錢包的功能和使用方法。 一、 Metamask錢包的功能 數字資 ......

    uj5u.com 2023-04-20 08:47:46 more
  • vulnhub_Earth

    前言 靶機地址->>>vulnhub_Earth 攻擊機ip:192.168.20.121 靶機ip:192.168.20.122 參考文章 https://www.cnblogs.com/Jing-X/archive/2022/04/03/16097695.html https://www.cnb ......

    uj5u.com 2023-04-20 07:46:20 more
  • 從4k到42k,軟體測驗工程師的漲薪史,給我看哭了

    清明節一過,盲猜大家已經無心上班,在數著日子準備過五一,但一想到銀行卡里的余額……瞬間心情就不美麗了。最近,2023年高校畢業生就業調查顯示,本科畢業月平均起薪為5825元。調查一出,便有很多同學表示自己又被平均了。看著這一資料,不免讓人想到前不久中國青年報的一項調查:近六成大學生認為畢業10年內會 ......

    uj5u.com 2023-04-20 07:44:00 more
  • 最新版本 Stable Diffusion 開源 AI 繪畫工具之中文自動提詞篇

    🎈 標簽生成器 由于輸入正向提示詞 prompt 和反向提示詞 negative prompt 都是使用英文,所以對學習母語的我們非常不友好 使用網址:https://tinygeeker.github.io/p/ai-prompt-generator 這個網址是為了讓大家在使用 AI 繪畫的時候 ......

    uj5u.com 2023-04-20 07:43:36 more
  • 漫談前端自動化測驗演進之路及測驗工具分析

    隨著前端技術的不斷發展和應用程式的日益復雜,前端自動化測驗也在不斷演進。隨著 Web 應用程式變得越來越復雜,自動化測驗的需求也越來越高。如今,自動化測驗已經成為 Web 應用程式開發程序中不可或缺的一部分,它們可以幫助開發人員更快地發現和修復錯誤,提高應用程式的性能和可靠性。 ......

    uj5u.com 2023-04-20 07:43:16 more
  • CANN開發實踐:4個DVPP記憶體問題的典型案例解讀

    摘要:由于DVPP媒體資料處理功能對存放輸入、輸出資料的記憶體有更高的要求(例如,記憶體首地址128位元組對齊),因此需呼叫專用的記憶體申請介面,那么本期就分享幾個關于DVPP記憶體問題的典型案例,并給出原因分析及解決方法。 本文分享自華為云社區《FAQ_DVPP記憶體問題案例》,作者:昇騰CANN。 DVPP ......

    uj5u.com 2023-04-20 07:43:03 more
  • msf學習

    msf學習 以kali自帶的msf為例 一、msf核心模塊與功能 msf模塊都放在/usr/share/metasploit-framework/modules目錄下 1、auxiliary 輔助模塊,輔助滲透(埠掃描、登錄密碼爆破、漏洞驗證等) 2、encoders 編碼器模塊,主要包含各種編碼 ......

    uj5u.com 2023-04-20 07:42:59 more
  • Halcon軟體安裝與界面簡介

    1. 下載Halcon17版本到到本地 2. 雙擊安裝包后 3. 步驟如下 1.2 Halcon軟體安裝 界面分為四大塊 1. Halcon的五個助手 1) 影像采集助手:與相機連接,設定相機引數,采集影像 2) 標定助手:九點標定或是其它的標定,生成標定檔案及內參外參,可以將像素單位轉換為長度單位 ......

    uj5u.com 2023-04-20 07:42:17 more
  • 在MacOS下使用Unity3D開發游戲

    第一次發博客,先發一下我的游戲開發環境吧。 去年2月份買了一臺MacBookPro2021 M1pro(以下簡稱mbp),這一年來一直在用mbp開發游戲。我大致分享一下我的開發工具以及使用體驗。 1、Unity 官網鏈接: https://unity.cn/releases 我一般使用的Apple ......

    uj5u.com 2023-04-20 07:40:19 more