笨方法學機器學習(三):卷積神經網絡

網絡結構的解析

  • 擁有不同的層,不同的網絡層有不同的功能
卷積神經網絡示意圖
  • 輸入層:數據的預處理
  • conv卷積層:卷積神經網路的核心網絡層,用一組權重通過窗口滑動同時計算輸入層
  • relu 激活函數層,把卷積層的結果做非線性映射
  • pool 池化層,壓縮數據和參數的量,減少過擬合
  • fc 全連接層。神經網絡中的全連接層,通常在卷積神經網絡的尾部,用來更好的擬合數據

一般的的卷積神經網絡結構為:
$$input->[[conv->relu]*N]->poll] *M->[fc -> relus] *K$$
其中N,M,k代表了層結構的數量

上圖中網絡結構我們可以表示為:
$$ INPUT -> [[CONV-relu]1 -> POOL]2 -> [FC]*2$$

即是:$$N=1,M=2,K=2$$

我們看到卷積神經網絡跟全連接神經網絡不同之處在于:

  • 卷積神經網絡的神經元是按三維排列的,有用其深度寬度高度

圖說明:

從上面的圖表示的神經網絡中,

  1. 輸入層的寬度跟高度跟圖像的寬度跟高度對應,其深度為1
  2. 第一個卷積層對輸入層的圖像進行了卷積處理,得到3個feature map,'3個'因為這個卷積層包含了3Filter(三個不同的權值矩陣),每個Filter都可以把輸入層的數據卷積到一個Feature map. 通常Filter 也被稱作通道(channel)
  3. 在第一個卷積層后,Pooling層對三個feature map做了一個下采樣處理,得到了三個更小的feature map
  4. 然后到了第二層卷積層,5個filter 對上一層的三個Feature map處理得到新的5個Feature Map,接著繼續進行Pooling層,得到5個更小的Feature Map,然后就是第二個Pooling層,進行下采樣,,這樣得到5個更小的Feature Map
  5. 最后兩層是全連接層,第一個全連接層的每個神經元,和上一層的5個Feature Map中的每個神經元連接,第二個全連接層(輸出層),則與第一個全連接層的神經元相連,這樣得到整個網絡的輸出

一、輸入層:

  • 輸入層一般進行數據的處理

  • 去均值:
    如圖:



    左邊是原始數據,右邊是去均值后的數據
    它將數據中心整體移動到0點,方便做計算(做求導計算的時候減少計算量)

  • 歸一化:
    歸一化是為了減少計算范圍,避免某一個特征維在計算的時候比重過大(在決策樹中使用過)
    歸一化公式:$$a=a/(max-min)$$
    它將所有維的特征值的變化都縮小到0-1的范圍內

二、Relu層(激活層)

之所以叫relu層是因為這個層是采用Relu函數作為激活函數,對卷積層的輸出進行激活處理,一般跟Relu層跟卷積層合在一層叫做卷積層.

Relu函數的定義是:
$$F(x)=max(0,x)$$

它的導數是分段的:
$f'(x)=0 ,x<0$
$f'(x)=1 ,x>0$

當然,你也可以選擇其他函數做為卷積層的激勵函數:
比如:

  • Sigmid
  • Tanh
  • Leaky Relu

三、卷積層的解析

(1)卷積的計算

假設有一個5*5的圖像,使用一個3*3的filter進行卷積,想得到一個3*3的Feature Map,如下所示:

我們對矩陣的元素進行標記:

表示圖像矩陣的第i行第j列元素,用

代表Filter矩陣的第m行,第n列權重,用
表示Filter的偏置項,用
代表了feature map的第i行第j列元素,用f 表示激活函數,這里的激活函數我們選取Relu激活函數,然后使用一下公式計算卷積:

(即在第0,0格中,3*3的窗口固定在image矩陣的左上角位置,3*3的矩陣與3*3的Filter矩陣對應位置相乘然后取和,然后其激活函數的輸出作為輸出矩陣的第0,0格的值)
如:


第二個位置時候(A0,1):
3*3的窗口往右移動一個步長(strike,這個例子步長為1):然后依次對應位置相乘取和,再通過激活函數,得到A0,1的值:


下面的動圖展示了Feature Map的每個位置的計算:


上面的情況是步長為1的計算過程,事實上,步長就是窗口(與Filter同樣大小的)每次移動的距離,當步長為2的時候:



我們看到Feature Map的大小跟步長的設定有關系:

  • W2是卷積后Feature Map的寬度,W1是卷積前的圖像寬度,F是Filter的寬度,P是Zero Pading的數量(指在原始圖像周圍補幾圈0),S是步長,
  • H同理是其高度表示

當卷積層的深度大于1,即多個Filter疊在一起同時計算時,只要把深度那一維也同時進行對應位置相乘然后加到之前的和里面經過激勵函數輸出即可,計算公式為:



下面的動圖介紹了RGB三維圖像經過2個3*3*3的Filter后計算過程:

這里體現了一些卷積神經網絡的特征:

  • 權重共享 Filter的權值對于上一層的所有神經元都是一樣的
  • 局部相連:每一層的神經元只與上一層的部分神經元相連

對于兩個3*3*3的卷積層來說,其參數數量僅有(3*3*3+1)*2=56個,且參數數量與上一層神經元個數無關.

卷積公式表示卷積層

數學中的二維卷積公式為:


我們可以將其表示為:
$$C=A*B$$

我們如果按上面公式計算,其實會發現A其實是Filter,而矩陣B是待輸入的矩陣,而位置關系也不同:


卷積層中的"卷積"看上去是將input矩陣旋轉了180°進行計算然后把AB位置調換進行的卷積計算,這種計算其實叫做'互相關'

如果我們不考慮這些小的差別的話.步長為1的卷積層計算我們可以簡化公式為:


四、Pooling層

Pooling層也叫池化層或者下采樣層,它將卷積層得到的輸出進行采集"有用"數據并抽象化,從而減少參數減少計算量.
池化方法常用的有:Max Pooling 跟Mean Pooling兩種,
前者求窗口最大值作為池化后的該格子的輸出值,后者取平均值作為格子輸出值


Pooling 層的深度跟卷積層數深度一樣,所以是卷積的各層Feature Map分別做Pooling處理

全連接層

全連接層的輸出值計算與訓練與上篇一樣

訓練與數學推導(數學公式重災區)

整個卷積神經網絡的訓練我們都采用反向傳播(Back Propagatio)算法進行訓練,算法 的介紹為:


其中損失函數$Ed$的定義為:
取網絡所有輸出層節點的誤差平方作為損失函數:


卷積誤差項的傳遞

我們先來考慮步長為1,輸入深度為1,Filter個數為1的最簡單情況:
假設輸入的大小為3*3,filter大小為2*2,按步長為1卷積,我們將得到2*2的feature map。如下圖所示:


在這里,我們假設第l層的每個誤差項

都是已知的,我們需要求上一層的誤差項

根據鏈式法則:

我們先來求第一項:



我們發現,計算

,相當于把第l層的sensitive map 周圍補一圈0,然后再跟旋轉180°的Filter進行互相關cross-correlation(卷積層的對應位置相乘然后相加操作)
如圖:

因為數學意義的[卷積]和互相關操作是可以轉化的。首先,我們把矩陣A翻轉180度,然后再交換A和B的位置(即把B放在左邊而把A放在右邊。卷積滿足交換率,這個操作不會導致結果變化),那么卷積就變成了互相關。
那么,上面的計算可以用卷積公式表示:



Wl代表了第l層的Filter的權重矩陣,
和的形式:


現在我們來看第二項

:



所以只要對激活函數Relu求導即可:

所以兩項相乘:



改寫成卷積形式:

代表了互相關操作.
這樣步長為1、輸入的深度為1、filter個數為1的最簡單的情況,卷積層誤差項傳遞的算法。

步長為S的時候:

我們可以看出,因為步長為2,得到的feature map跳過了步長為1時相應的部分。

因此,當我們反向計算誤差項的時候,我們可以對步長為S的Feature Map的響應位置進行補0,將其[還原]成步長為1時的sensitivity map,再用以下式子進行計算

Filter數量為N時候的誤差傳遞:



D為深度

卷積Filter權重梯度的計算:


計算

:

由于是權值共享,權值
的計算對所有的
都有影響,根據全導數公式:

計算:

所以其公式是:


偏置項的梯度:



所有sensivity map所有的誤差項之和.

對于步長為S的卷積層,處理方法跟傳遞誤差項是一樣的:

  • 將sensitivity map還原成步長為1的sensitivity map,
  • 再用上面的方法計算

獲得了所有的梯度之后,根據梯度下降算法更新每個權重.

梯度下降,簡單來說就是:
$$w=w-f'(w)$$

這樣,我們就解決了卷積層的訓練

Pooling層的訓練:

max pooling 跟Mean pooling都沒有參數,所以需要將誤差項傳遞到上一層

max Pooling層

不妨設最大值為

所以得:



所以,對于max pooling,下一層的誤差項的值會原封不動的傳遞到上一層區塊的最大值對應的神經元,而其他神經元的誤差項的值都是0

mean Pooling層


所以對于mean Pooling層,下一層的誤差項會平均分配到上一層所對應 的區塊中.


全連接層

參看 神經網絡這篇博客的介紹:

代碼實現:

導入工具包:

import numpy as np

卷積層的初始化:

class ConvLayer(object):
    def __init__(self,input_width,input_height
                 ,channel_number,filter_width
                 ,filter_height,filter_number
                 ,zero_padding,stride,activator,
                 learning_rate):
        '''
        :param input_width: 輸入矩陣的寬
        :param input_height:輸入矩陣的高
        :param channel_number:
        :param filter_width:共享權重的filter矩陣寬
        :param zero_padding:補幾圈0
        :param stride:窗口每次移動的步長
        :param activator:激勵函數
        :param learning_rate:學習率
        :param filter_height共享權重的filter矩陣寬
        :param filter_number filter的深度
        '''
        self.input_width = input_height
        self.input_height = input_height
        self.channel_number = channel_number
        self.filter_width = filter_width
        self.filter_height = filter_height
        self.filter_number = filter_number
        self.zero_padding = zero_padding
        self.stride = stride
        self.activator = activator
        self.learning_rate = learning_rate
        self.output_height = ConvLayer.calculate_output_size(
            self.input_height,filter_height,zero_padding,stride
        )
        self.output_width = ConvLayer.calculate_output_size(
            self.input_width,self.filter_width,zero_padding,stride
        )
        self.output_array = np.zeros((self.filter_number,
                                      self.output_height,
                                      self.output_width))
        self.filters = []
        for i in range(filter_number):
            self.filters.append(Filter(filter_width,
                                       filter_height,
                                       self.channel_number))

calculate_output_size函數用來計算卷積層輸出:

@staticmethod
    def calculate_output_size(input_size,filter_size
                              ,zero_padding,stride):
        return int( (input_size - filter_size + 2 * zero_padding) / stride + 1)

定義Filter類與Relu激活函數:

class Filter(object):
    #Filter 類 保存了卷積層的參數以及梯度,并用梯度下降的辦法更新參數
    #權重隨機初始化為一個很小的值,而偏置項初始化為0。
    def __init__(self,width,height,depth):
        self.weights = np.random.uniform(-1e-4,1e-4,(depth,height,width))
        self.bias =0
        self.weights_grad = np.zeros(self.weights.shape)
        self.bias_grad = 0
    def __repr__(self):
        return 'filter weights:\n%s\nbias:\n%s' % (
            repr(self.weights), repr(self.bias))
    def get_weights(self):
        return self.weights

    def get_bias(self):
        return self.bias

    def update(self,learning_rate):
        self.weights -= learning_rate * self.weights_grad
        self.bias -= learning_rate * self.bias_grad
class ReluActivator(object):
    def forward(self,weighted_input):
        return max(0,weighted_input)
    def backward(self,output):
        return 1 if output > 0 else 0

卷積層前計算:

    def forward(self,input_array):
        '''
        計算卷積層的輸出
        :param input_array: 前一層的輸出
        :return: 沒有返回,輸出結果保存到self.output_array
        '''
        self.input_array = input_array
        self.padded_input_array = padding(input_array,self.zero_padding)
        for f in range(self.filter_number):
            filter = self.filters[f]
            conv(self.padded_input_array,filter.get_weights(),
                 self.output_array[f],self.stride,filter.get_bias())
        element_wise_op(self.output_array,self.activator.forward)

前計算用到的 工具函數:

def padding(input_array, zp):
    '''
    將輸入矩陣補0
    :param input_array:
    :param zp: 補0的圈數
    :return:
    python3 玄學除法,int 變float
    '''
    zp = int(zp)
    if zp ==0:
        return input_array
    else:
        if input_array.ndim==3:
            input_width = input_array.shape[2]
            input_height = input_array.shape[1]
            input_depth = input_array.shape[0]
            padder_array = np.zeros((input_depth,input_height+2*zp,input_width+2*zp))
            padder_array[:,zp:zp+input_height,zp:zp+input_width]=input_array
            return padder_array
        elif input_array.ndim==2:
            input_height = input_array.shape[0]
            input_width = input_array.shape[1]
            padder_array = np.zeros((input_height+2*zp,input_width+2*zp))
            padder_array[zp:zp+input_height,zp:zp+input_width]=input_array
            return padder_array
def element_wise_op(array, op):
    '''
    對numpy數組元素依次進行op操作(這里是函數)
    :param array:
    :param op:
    :return:
    '''
    for i in np.nditer(array,
                       op_flags=['readwrite']):
        i[...] = op(i)
def conv(input_array,kernel_array,output_array,stride,bias):
    '''
    計算卷積
    :param input_array:
    :param kernel_array:
    :param output_array:
    :param stride:
    :param bias:
    :return:
    '''
    channel_number = input_array.ndim
    output_width = output_array.shape[1]
    output_height = output_array.shape[0]
    kernel_width = kernel_array.shape[-1]
    kernel_height = kernel_array.shape[-2]
    for i in range(output_height):
        for j in range(output_width):
            #依次計算每一格的卷積
            output_array[i][j] = (get_patch(input_array,i,j,
                                            kernel_width,kernel_height,stride) * kernel_array ).sum()+bias
def get_patch(input_array, i, j, kernel_width,
                    kernel_height, stride):
    '''
    獲得窗口移動后input的array
    '''
    i*=stride
    j*=stride
    max_height = i + kernel_height
    max_width = j + kernel_width
    if input_array.ndim == 3:
        max_z = input_array.shape[0] + 1
        return input_array[0:max_z, i:max_height, j:max_width]
    else:
        return input_array[i:max_height, j:max_width]

卷積層的反向傳播代碼,這些方法都在ConvLayer類中:

    def bp_sensitivity_map(self,sensitivity_array,activator):
        '''
        卷積層反向傳播算法的實現
        1,將誤差項傳遞到上一層
        2:計算每個參數的梯度
        3:更新參數
        :param sensitivity_array: 本層的sensitivity map
        :param activator: 上一層的激活函數
        :return:
        '''
        expanded_array = self.expand_sensitivity_map(sensitivity_array)
        #full 卷積
        expanded_width = expanded_array.shape[2]
        #獲得補0 數
        zp = (self.input_width+self.filter_width-1-expanded_width)/2
        padded_array = padding(expanded_array,zp)
        #創建初始誤差矩陣
        self.delta_array = self.create_delta_array()

        #對于具有多個filter的卷積層來說,最終傳遞到上一層的sensitivity map
        #相當于把所有的filter的sensitivity map之和
        for f in range(self.filter_number):
            filter = self.filters[f]
            # 將filter權重翻轉180度

            flipped_weights = np.array(map(
                lambda i: np.rot90(i, 2),
                filter.get_weights()))
            #python3運行的時候這個map有問題
            #計算每一個filter的delta_array
            delta_array = self.create_delta_array()
            for d in range(delta_array.shape[0]):
                conv(padded_array[f],flipped_weights[d],delta_array[d],1,0)
            self.delta_array+=delta_array
        #創建激活函數矩陣(卷積反向傳播誤差項的第二項)
        derivative_array = np.array(self.input_array)
        element_wise_op(derivative_array,self.activator.backward)
        self.delta_array *= derivative_array

    def bp_gradient(self,sensitivity_array):
        '''
        計算梯度,包括權重跟偏置項
        :param sensitivity_array:
        :return:
        '''
        expanded_array = self.expand_sensitivity_map(sensitivity_array)
        for f in range(self.filter_number):
            filter = self.filters[f]
            for d in range(filter.get_weights().shape[0]):
                conv(self.padded_input_array[d],expanded_array[f],
                    filter.weights_grad[d],1,0)
            filter.bias_grad = expanded_array[f].sum()
    def expand_sensitivity_map(self,sensitivity_array):
        '''
        將步長為S的map 還原成步長1的map
        :param sensitivity_array:
        :return:
        '''
        expanded_depth = sensitivity_array.shape[0]
        expanded_height = (self.input_height-self.filter_height+2*self.zero_padding+1)
        expanded_width = (self.input_width-self.filter_width+2*self.zero_padding+1)
        expanded_array = np.zeros((expanded_depth,expanded_height,expanded_width))
        for i in range(self.output_height):
            for j in range(self.output_width):
                i_pos = i * self.stride
                j_pos = j * self.stride
                expanded_array[:,i_pos,j_pos]=sensitivity_array[:,i,j]
        return expanded_array
    def create_delta_array(self):
        return np.zeros((self.channel_number,self.input_height,self.input_width))
    def update(self):
        '''
        更新這一層的權重跟偏置項,很簡單依次更新每一個filter就行了
        :return:
        '''
        for filter in self.filters:
            filter.update(self.learning_rate)
    def backward(self, sensitivity_array, activator=None):
        if not activator:
            activator = self.activator
        self.bp_sensitivity_map(sensitivity_array, activator)
        self.bp_gradient(sensitivity_array)

為了驗證我們的卷積層是否寫得正確,驗證代碼:

from conv import *
#conv是我卷積層類所在的python文件,此處導入模塊
import numpy as np
class IdentityActivator(object):
    def forward(self, weighted_input):
        #return weighted_input
        return weighted_input
    def backward(self, output):
        return 1
def init_test():
    a = np.array(
        [[[0,1,1,0,2],
          [2,2,2,2,1],
          [1,0,0,2,0],
          [0,1,1,0,0],
          [1,2,0,0,2]],
         [[1,0,2,2,0],
          [0,0,0,2,0],
          [1,2,1,2,1],
          [1,0,0,0,0],
          [1,2,1,1,1]],
         [[2,1,2,0,0],
          [1,0,0,1,0],
          [0,2,1,0,1],
          [0,1,2,2,2],
          [2,1,0,0,1]]])
    b = np.array(
        [[[0,1,1],
          [2,2,2],
          [1,0,0]],
         [[1,0,2],
          [0,0,0],
          [1,2,1]]])
    cl = ConvLayer(5,5,3,3,3,2,1,2,IdentityActivator(),0.001)
    cl.filters[0].weights = np.array(
        [[[-1,1,0],
          [0,1,0],
          [0,1,1]],
         [[-1,-1,0],
          [0,0,0],
          [0,-1,0]],
         [[0,0,-1],
          [0,1,0],
          [1,-1,-1]]], dtype=np.float64)
    cl.filters[0].bias=1
    cl.filters[1].weights = np.array(
        [[[1,1,-1],
          [-1,-1,1],
          [0,-1,1]],
         [[0,1,0],
         [-1,0,-1],
          [-1,1,0]],
         [[-1,0,0],
          [-1,0,1],
          [-1,0,0]]], dtype=np.float64)
    return a, b, cl
def gradient_check():
    '''
    梯度檢查
    '''
    # 設計一個誤差函數,取所有節點輸出項之和
    error_function = lambda o: o.sum()
    # 計算forward值
    a, b, cl = init_test()
    cl.forward(a)
    # 求取sensitivity map,是一個全1數組
    sensitivity_array = np.ones(cl.output_array.shape,
                                dtype=np.float64)
    # 計算梯度
    cl.backward(sensitivity_array,
                  IdentityActivator())
    cl.update()
    # 檢查梯度
    epsilon = 10e-4
    for d in range(cl.filters[0].weights_grad.shape[0]):
        for i in range(cl.filters[0].weights_grad.shape[1]):
            for j in range(cl.filters[0].weights_grad.shape[2]):
                cl.filters[0].weights[d,i,j] += epsilon
                cl.forward(a)
                err1 = error_function(cl.output_array)
                cl.filters[0].weights[d,i,j] -= 2*epsilon
                cl.forward(a)
                err2 = error_function(cl.output_array)
                expect_grad = (err1 - err2) / (2 * epsilon)
                cl.filters[0].weights[d,i,j] += epsilon
                print('weights(%d,%d,%d): expected - actural %f - %f' % (
                    d, i, j, expect_grad, cl.filters[0].weights_grad[d,i,j]))
gradient_check()
image.png

Max Pooling 層的實現:

# -*- coding:utf-8 -*-
#!/usr/bin/local/bin/python
import numpy as np
from tools import *
class MaxPoolingLayer(object):
    def __init__(self,input_width,input_height,
                 channel_number,filter_width,
                 filter_height,stride):
        self.input_width = input_width
        self.input_height = input_height
        self.channel_number = channel_number
        self.filter_width = filter_width
        self.filter_height = filter_height
        self.stride = stride
        self.output_width = (input_width-filter_width)/self.stride + 1
        self.output_height = (input_height - filter_height)/self.stride +1
        self.output_array = np.zeros((self.channel_number,
                                      self.output_height,self.output_width))

    def forward(self,input_array):
        self.input_array = input_array
        for d in range(self.channel_number):
            for i in range(self.output_height):
                for j in range(self.output_width):
                    self.output_array[d,i,j] = (get_patch(
                        input_array[d],i,j,
                        self.filter_width,
                        self.filter_height,
                        self.stride
                    ).max())

    def backward(self,sensitivity_array):
        self.delta_array = np.zeros(self.input_array.shape)
        for d in range(self.channel_number):
            for i in range(self.output_height):
                for j in range(self.output_width):
                    patch_array = get_patch(
                        self.input_array[d],i,j,
                        self.filter_width,
                        self.filter_height,
                        self.stride
                    )
                    k,l =get_max_index(patch_array)
                    self.delta_array[d,
                                     i*self.stride+k,
                                    j*self.stride+l] = sensitivity_array[d,i,j]
    def update(self):
        #因為不需要進行更新權重,所以此方法pass,但是為了保證整個網絡更新的時候可以用layers.update()方法統一更新權值,所以寫了個空方法
        pass

tools文件定義了兩個工具方法:

# -*- coding:utf-8 -*-
import numpy as np
def get_patch(input_array, i, j, kernel_width,
                    kernel_height, stride):
    '''
    獲得窗口移動后input的array
    '''
    i*=stride
    j*=stride
    max_height = i + kernel_height
    max_width = j + kernel_width
    if input_array.ndim == 3:
        max_z = input_array.shape[0] + 1
        return input_array[0:max_z, i:max_height, j:max_width]
    else:
        return input_array[i:max_height, j:max_width]
def get_max_index(arr):
    '''
    獲取數組中的最大值,返回坐標
    :param arr:
    :return:
    '''
    idx = np.argmax(arr)
    return (int(idx / arr.shape[1]), idx % arr.shape[1])

全連接層 fc.py:

# -*- coding:utf-8 -*-
#!/usr/bin/local/bin/python

import numpy as np

class FullConnectedLayer(object):
    def __init__(self,input_size,
                 output_size,
                 learing_rate,
                 activator):
        self.input_size = input_size
        self.output_size = output_size
        self.activator = activator
        self.learning_rate = learing_rate
        self.W = np.random.uniform(-0.1,0.1,(output_size,input_size))
        self.b = np.zeros((output_size,1))
        self.output = np.zeros((output_size,1))

    def forward(self,input_array):
        self.input = input_array
        self.output = self.activator.forward(
            np.dot(self.W,input_array)+self.b
        )

    def backward(self,delta_array):
        self.delta = self.activator.backward(self.input) * np.dot(
            self.W.T,delta_array
        )
        self.W_grad = np.dot(delta_array,self.input.T)
        self.b_grad = delta_array

    def update(self):
        self.W += self.learning_rate * self.W_grad
        self.b += self.learning_rate * self.b_grad

這里我們完成了卷積層,pooling(池化)層,全連接層的定義,我們就可以用這些簡陋的輪子搭建一個簡單的卷積神經網絡了.
為什么寫這些代碼而不用框架:

  • 框架是別人寫好的輪子,只會用框架不能理解其中的含義
  • 自己寫的代碼更容易理解其中的算法與數學含義
  • 個人建議,學習階段寫輪子,使用階段用別人的輪子

我用上面的代碼搭建了一個巨簡陋的卷積神經網絡去試試MNIST手寫數字數據集的識別
完整代碼在:

git clone  https://github.com/coldsummerday/mllearn.git
cd mllearn-master/cnn/
#建議用python2,用python3的朋友吧conv文件中的bp_sensitivity_map中有個map方法改成python3的即可
python main.py 
#good luck!
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容