極限學習機(ELM)從原理到程序實現(附完整代碼)

圖片展示

摘要:極限學習機(ELM)是當前一類非常熱門的機器學習算法,被用來訓練單隱層前饋神經網絡(SLFN)。本篇博文儘量通俗易懂地對極限學習機的原理進行詳細介紹,之後分析如何用MATLAB實現該算法並對代碼進行解釋。本文主要內容如下:

點擊跳轉至全部文件下載頁


1. 前言

    ELM自2004年南洋理工大學的黃廣斌教授提出相關概念以來一直爭議不斷,但每年相關論文層出不窮,在過去的十年裏其理論和應用被廣泛研究。如果您想深入學習和了解ELM的原理,博主建議可在ScienceDirect的數據庫中檢索ELM相關論文,裏面有衆多優質論文其理解和表述將幫助你更準確瞭解ELM的內在原理。
    博主對於極限學習機(Extreme Learning Machine, ELM)的學習和研究已一年多,目前ELM相關兩篇SCI論文已發表。後續轉深度學習方向繼續研究,這裏就ELM作一個簡單整理並給出實現代碼,也希望對剛接觸的朋友能夠有所幫助。


2. 算法的原理

    極限學習機(ELM)用來訓練單隱藏層前饋神經網絡(SLFN)與傳統的SLFN訓練算法不同,極限學習機隨機選取輸入層權重和隱藏層偏置,輸出層權重通過最小化由訓練誤差項和輸出層權重範數的正則項構成的損失函數,依據Moore-Penrose(MP)廣義逆矩陣理論計算解析求出。理論研究表明,即使隨機生成隱藏層節點,ELM仍保持SLFN的通用逼近能力。在過去的十年裏,ELM的理論和應用被廣泛研究,從學習效率的角度來看,極限學習機具有訓練參數少、學習速度快、泛化能力強的優點。

    簡單來說,極限學習機(ELM)模型的網絡結構與單隱層前饋神經網絡(SLFN)一樣,只不過在訓練階段不再是傳統的神經網絡中屢試不爽的基於梯度的算法(後向傳播),而採用隨機的輸入層權值和偏差,對於輸出層權重則通過廣義逆矩陣理論計算得到。所有網絡節點上的權值和偏差得到後極限學習機(ELM)的訓練就完成了,這時測試數據過來時利用剛剛求得的輸出層權重便可計算出網絡輸出完成對數據的預測。

    ELM的理論推導比較簡單,但是需要知道一些線性代數和矩陣論的知識,我會盡量寫得簡單一些,接下來就一起來推導一下吧。我們假設給定訓練集{xi,tixiRD,tiRm,i=1,2,,N}\left\{\mathrm{x}_{i}, \mathrm{t}_{i} | \mathrm{x}_{i} \in \mathrm{R}^{D}, \mathrm{t}_{i} \in \mathrm{R}^{m}, i=1,2, \ldots, N\right\}(符號式表達,xi\mathrm{x}_{i}表示第ii個數據示例,ti\mathrm{t}_{i}表示第ii個數據示例對應的標記,集合代指所有訓練數據),極限學習機的隱藏層節點數爲 L,與單隱層前饋神經網絡的結構一樣,極限學習機的網絡結構如下圖所示:

圖片展示

    對於一個神經網絡而言,我們完全可以把它看成一個“函數”,單從輸入輸出看就顯得簡單許多。很明顯上圖中,從左往右該神經網絡的輸入是訓練樣本集x\mathrm{x},中間有個隱藏層,從輸入層到隱藏層之間是全連接,記隱藏層的輸出爲H(x)H(\mathrm{x}),那麼隱藏層輸出H(x)H(\mathrm{x})的計算公式如下。
(2.1)H(x)=[h1(x),,hL(x)]H(\mathrm{x})=\left[h_{1}(\mathrm{x}), \ldots, h_{L}(\mathrm{x})\right] \tag{2.1}     隱藏層的輸出是輸入乘上對應權重加上偏差,再經過一個非線性函數其所有節點結果求和得到。H(x)=[h1(x),,hL(x)]\mathrm{H}(\mathrm{x})=\left[h_{1}(\mathrm{x}), \ldots, h_{L}(\mathrm{x})\right]ELM非線性映射(隱藏層輸出矩陣),hi(x)h_{i}(\mathrm{x})是第ii個隱藏層節點的輸出。隱藏層節點的輸出函數不是唯一的,不同的輸出函數可以用於不同的隱藏層神經元。通常,在實際應用中,hi(x)h_{i}(\mathrm{x})用如下的表示:
(2.2)hi(x)=g(wi,bi,x)=i=1Lg(wix+bi),wiRD,biRh_{i}(\mathrm{x})=g\left(\mathrm{w}_{i}, b_{i}, \mathrm{x}\right)=\sum_{i=1}^{L}g(\mathrm{w}_{i}\mathrm{x}+\mathrm{b}_{i}), \mathrm{w}_{i} \in \mathrm{R}^{D}, b_{i} \in R\tag{2.2}     其中g(wi,bi,x)g\left(\mathrm{w}_{i}, b_{i}, \mathrm{x}\right)wi\mathrm{w}_{i}bib_{i}是隱藏層節點參數)是激活函數,是一個滿足ELM通用逼近能力定理的非線性分段連續函數,常用的有Sigmoid函數、Gaussian函數等。如我們可以使用Sigmoid函數,那麼式(2.2)中的gg函數爲(2.3)g(x)=11+ex=exex+1g(x)=\frac{1}{1+e^{-x}}=\frac{e^{x}}{e^{x}+1}\tag{2.3}(將wix+bi\mathrm{w}_{i}\mathrm{x}+\mathrm{b}_{i}代入xx即可),該函數圖像如下圖所示

圖片展示

    經過隱層後進入輸出層,根據上面的圖示和公式,那麼用於“廣義”的單隱藏層前饋神經網絡ELM的輸出是
(2.4)fL(x)=i=1Lβihi(x)=H(x)βf_{L}(\mathrm{x})=\sum_{i=1}^{L} \boldsymbol{\beta}_{i} h_{i}(\mathrm{x})=\mathrm{H}(\mathrm{x}) \boldsymbol{\beta} \tag{2.4} 其中β=[β1,,βL]T\boldsymbol{\beta}=\left[\boldsymbol{\beta}_{1}, \ldots, \boldsymbol{\beta}_{L}\right]^{T}是隱藏層(LL個節點)與輸出層(mm個節點,m1m \geq 1)之間的輸出權重。至此神經網絡從輸入到輸出的操作就是上面公式的計算過程。需要注意的是,目前爲止上面公式中的未知量有w,b,β\mathrm{w}, \mathrm{b}, \boldsymbol{\beta},分別是隱藏層節點上的權值、偏差及輸出權值。我們知道神經網絡學習(或訓練)的過程就是根據訓練數據來調整神經元之間的權值以及偏差,而實際上學到的東西則蘊含在連接權值和偏差之中。接下來我們就要用ELM的機理來求解這三個值(ELM訓練過程)。

    基本上,ELM訓練SLFN分爲兩個主要階段:(1)隨機隨機特徵映射(2)線性參數求解

    第一階段,隱藏層參數隨機進行初始化,然後採用一些非線性映射作爲激活函數,將輸入數據映射到一個新的特徵空間(稱爲ELM特徵空間)。簡單來說就是ELM隱層節點上的權值和偏差是隨機產生的。隨機特徵映射階段與許多現有的學習算法(如使用核函數進行特徵映射的SVM、深度神經網絡中使用限制玻爾茲曼機器(RBM)、用於特徵學習的自動編碼器/自動解碼器)不同。ELM 中的非線性映射函數可以是任何非線性分段連續函數。在ELM中,隱藏層節點參數(wwbb)根據任意連續的概率分佈隨機生成(與訓練數據無關),而不是經過訓練確定的,從而致使與傳統BP神經網絡相比在效率方面佔很大優勢。

    經過第一階段w,b\mathrm{w}, \mathrm{b}已隨機產生而確定下來,由此可根據公式(2.1)和(2.2)計算出在ELM學習的第二階段,我們只需要求解輸出層的權值(β\beta)。爲了得到在訓練樣本集上具有良好效果的β\beta,需要保證其訓練誤差最小,我們可以用Hβ\mathrm{H} \boldsymbol{\beta}(網絡的輸出,如公式(2.4))與樣本標籤TT求最小化平方差作爲評價訓練誤差(目標函數),使得該目標函數最小的解就是最優解。即通過最小化近似平方差的方法對連接隱藏層和輸出層的權重(β\beta)進行求解,目標函數如下:
(2.5)minHβT,βRL×m\min \|\mathrm{H} \boldsymbol{\beta}-\mathrm{T}\| , \boldsymbol{\beta} \in \mathbf{R}^{L \times m} \tag{2.5} 其中H是隱藏層的輸出矩陣,T是訓練數據的目標矩陣:
(2.6)H=[h(x1),...,h(xN)]T=[h1(x1)...hL(x1)...h1(xN)...hL(xN)],T=[t1T...tNT]H=[h(x_1),...,h(x_{N})]^{T}=\begin{bmatrix} h_1(x_1)&...&h_L(x_1) \\ & ... & \\ h_1(x_N) &...&h_L(x_N) \end{bmatrix},T=\begin{bmatrix} t_{1}^{T}\\ ... \\ t_{N}^{T} \end{bmatrix} \tag{2.6} 通過線代和矩陣論的知識可推導得公式(2.5)的最優解爲:
(2.7)β=HT \beta^{*}=\mathrm{H}^{\dagger} \mathrm{T} \tag{2.7} 其中H\mathrm{H}^{\dagger}爲矩陣HMoore–Penrose廣義逆矩陣。

    這時問題就轉化爲求計算矩陣HMoore–Penrose廣義逆矩陣,該問題主要的幾種方法有正交投影法、正交化法、迭代法和奇異值分解法(SVD)。當HTHH^{T}H爲非奇異(不可逆)時可使用正交投影法,這時可得計算結果是:
H=(HTH)1HT\mathbf{H}^{\dagger}=\left(\mathbf{H}^{T} \mathbf{H}\right)^{-1} \mathbf{H}^{T}    然而有時候在應用時會出現HTHH^{T}H爲奇異的(不可逆)情況,因此正交投影法並不能很好地應用到所有的情況。由於使用了搜索和迭代,正交化方法和迭代方法具有侷限性。而奇異值分解(SVD)總是可以用來計算HHMoore–Penrose廣義逆,因此用於ELM的大多數實現中。具體的求解過程設計矩陣論的知識,而且在許多編程語言中都已封裝了求解廣義逆的可調用的函數,限於篇幅這裏博主就不多展開了。至此訓練部分全部完成,在測試時利用訓練得到的結果便可預測結果。

    綜合上面說的,對於訓練單隱層前饋神經網絡的極限學習機算法做出總結,算法如下所示:

算法:極限學習機(ELM)
輸入
    數據集:{xi,tixiRD,tiRm,i=1,2,,N}\left\{\mathrm{x}_{i}, \mathrm{t}_{i} | \mathrm{x}_{i} \in \mathrm{R}^{D}, \mathrm{t}_{i} \in \mathrm{R}^{m}, i=1,2, \ldots, N\right\}
    隱層神經元數目:LL
    激活函數:g(.)g( .)
輸出
    輸出權重:β\beta
    1.隨機產生輸入權重ww和隱層偏差bb
    2.計算隱藏層輸出H=g(xω+b)\mathrm{H}=\mathrm{g}(\mathrm{x} * \boldsymbol{\omega}+\mathrm{b})
    3.採用式(2.7)計算輸出層權重β\mathrm{\beta}

    其實以上的理論尚有不足之處,根據Bartlett理論,對於達到較小的訓練誤差的前饋神經網絡,通常其權值範數越小,網絡趨向於獲得更好的泛化性能。爲了進一步提高傳統極限學習機的穩定性和泛化能力,提出了優化等式約束的極限學習機,這裏就不多介紹了有興趣的可以瞭解一下。


3. 算法程序實現

    數學中的符號語言可謂精簡巧妙,其簡潔之美不經間引人入勝,同時它的複雜又讓人覺得自己知道的還很匱乏。作爲工科的我數學基本功僅限於剛考研時的臨陣磨槍,如今的研究生階段接觸了最優理論、統計分析和矩陣論的知識但自感尚不能得其萬一。上面的算法推導是完成了,但不代表程序就能完整照上面的步驟實現了,畢竟理論和實際總是有差距的。

    其實ELM的程序代碼早已開放,提供源碼下載的網站:黃廣斌老師的ELM資源主頁,上面已經有了MATLABC++pythonJava的版本,使用起來也比較方便。這裏博主就其中MATLAB的代碼介紹下算法的程序實現,爲方便初學者理解源碼,我只是在其中的大部分代碼中進行了註釋,當然爲避免個人理解的偏差建議可去官網查看英文源碼。

function [TrainingTime, TestingTime, TrainingAccuracy, TestingAccuracy] = ELM(TrainingData_File, TestingData_File, Elm_Type, NumberofHiddenNeurons, ActivationFunction)
%% ELM 算法程序
% 調用方式: [TrainingTime, TestingTime, TrainingAccuracy, TestingAccuracy] = elm(TrainingData_File, TestingData_File, Elm_Type, NumberofHiddenNeurons, ActivationFunction)
%
% 輸入:
% TrainingData_File     - 訓練數據集文件名
% TestingData_File      - 測試訓練集文件名
% Elm_Type              - 任務類型:0 時爲迴歸任務,1 時爲分類任務
% NumberofHiddenNeurons - ELM的隱層神經元數目
% ActivationFunction    - 激活函數類型:
%                           'sig' , Sigmoidal 函數
%                           'sin' , Sine 函數
%                           'hardlim' , Hardlim 函數
%                           'tribas' , Triangular basis 函數
%                           'radbas' , Radial basis 函數
% 輸出: 
% TrainingTime          - ELM 訓練花費的時間(秒)
% TestingTime           - 測試數據花費的時間(秒)
% TrainingAccuracy      - 訓練的準確率(迴歸任務時爲RMSE,分類任務時爲分類正確率)                       
% TestingAccuracy       - 測試的準確率(迴歸任務時爲RMSE,分類任務時爲分類正確率)
%
% 調用示例(迴歸): [TrainingTime, TestingTime, TrainingAccuracy, TestingAccuracy] = ELM('sinc_train', 'sinc_test', 0, 20, 'sig')
% 調用示例(分類): [TrainingTime, TestingTime, TrainingAccuracy, TestingAccuracy] = ELM('diabetes_train', 'diabetes_test', 1, 20, 'sig')


%% 數據預處理

% 定義任務類型
REGRESSION=0;
CLASSIFIER=1;

% 載入訓練數據集
train_data=load(TrainingData_File);
T=train_data(:,1)';                   % 第一列:分類或迴歸的期望輸出
P=train_data(:,2:size(train_data,2))';% 第二列到最後一列:不同數據的屬性
clear train_data;                     % 清除中間變量

% 載入測試數據集
test_data=load(TestingData_File);
TV.T=test_data(:,1)';                  % 第一列:分類或迴歸的期望輸出
TV.P=test_data(:,2:size(test_data,2))';% 第二列到最後一列:不同數據的屬性
clear test_data;                       % 清除中間變量

% 獲取訓練、測試數據情況
NumberofTrainingData=size(P,2);        % 訓練數據中分類對象個數
NumberofTestingData=size(TV.P,2);      % 測試數據中分類對象個數
NumberofInputNeurons=size(P,1);        % 神經網絡輸入個數,訓練數據的屬性個數

%% 分類任務時的數據編碼
if Elm_Type~=REGRESSION
    % 分類任務數據預處理
    sorted_target=sort(cat(2,T,TV.T),2);% 將訓練數據和測試數據的期望輸出合併成一行,然後按從小到大排序
    label=zeros(1,1);                   %  Find and save in 'label' class label from training and testing data sets
    label(1,1)=sorted_target(1,1);      % 存入第一個標籤
    j=1;
    % 遍歷所有數據集標籤(期望輸出)得到數據集的分類數目
    for i = 2:(NumberofTrainingData+NumberofTestingData)
        if sorted_target(1,i) ~= label(1,j)
            j=j+1;
            label(1,j) = sorted_target(1,i);
        end
    end
    number_class=j;                    % 統計數據集(訓練數據和測試數據)一共有幾類
    NumberofOutputNeurons=number_class;% 一共有幾類,神經網絡就有幾個輸出
       
    % 預定義期望輸出矩陣
    temp_T=zeros(NumberofOutputNeurons, NumberofTrainingData);
    % 遍歷所有訓練數據的標記,擴充爲num_class*NumberofTraingData的矩陣 
    for i = 1:NumberofTrainingData
        for j = 1:number_class
            if label(1,j) == T(1,i)
                break; 
            end
        end
        temp_T(j,i)=1;                %一個矩陣,行是分類,列是對象,如果該對象在此類就置1
    end
    T=temp_T*2-1;                     % T爲處理的期望輸出矩陣,每個對象(列)所在的真實類(行)位置爲1,其餘爲-1

    % 遍歷所有測試數據的標記,擴充爲num_class*NumberofTestingData的矩陣 
    temp_TV_T=zeros(NumberofOutputNeurons, NumberofTestingData);
    for i = 1:NumberofTestingData
        for j = 1:number_class
            if label(1,j) == TV.T(1,i)
                break; 
            end
        end
        temp_TV_T(j,i)=1;            % 期望輸出表示矩陣,行是分類,列是對象,如果該對象在此類就置1
    end
    TV.T=temp_TV_T*2-1;              % T爲處理的期望輸出矩陣,每個對象(列)所在的真實類(行)位置爲1,其餘爲-1


end  % Elm_Type

%% 計算隱藏層的輸出H
start_time_train=cputime;           % 訓練開始計時

% 隨機產生輸入權值InputWeight (w_i)和隱層偏差biases BiasofHiddenNeurons (b_i)
InputWeight=rand(NumberofHiddenNeurons,NumberofInputNeurons)*2-1; % 輸入節點的權重在[-1,1]之間
BiasofHiddenNeurons=rand(NumberofHiddenNeurons,1);                % 連接偏重在[0,1]之間
tempH=InputWeight*P; % 不同對象的屬性*權重
clear P; % 釋放空間 
ind=ones(1,NumberofTrainingData);     % 訓練集中分類對象的個數
BiasMatrix=BiasofHiddenNeurons(:,ind);% 擴展BiasMatrix矩陣大小與H匹配 
tempH=tempH+BiasMatrix;               % 加上偏差的最終隱層輸入

%計算隱藏層輸出矩陣
switch lower(ActivationFunction) % 選擇激活函數,lower是將字母統一爲小寫
    case {'sig','sigmoid'}
        H = 1 ./ (1 + exp(-tempH));% Sigmoid 函數
    case {'sin','sine'}
        H = sin(tempH);            % Sine 函數
    case {'hardlim'}
        H = double(hardlim(tempH));% Hardlim 函數
    case {'tribas'}
        H = tribas(tempH);         % Triangular basis 函數
    case {'radbas'}
        H = radbas(tempH);         % Radial basis 函數
    % 可在此添加更多激活函數                
end
clear tempH;% 釋放不在需要的變量

%% 計算輸出權重 OutputWeight (beta_i)
OutputWeight=pinv(H') * T';   % 無正則化因子的應用,參考2006年 Neurocomputing 期刊上的論文
% OutputWeight=inv(eye(size(H,1))/C+H * H') * H * T';   % faster method 1 ,參考 2012 IEEE TSMC-B 論文
% OutputWeight=(eye(size(H,1))/C+H * H') \ H * T';      % faster method 2 ,refer to 2012 IEEE TSMC-B 論文

end_time_train=cputime;
TrainingTime=end_time_train-start_time_train; % 計算訓練ELM時CPU花費的時間

% 計算輸出
Y=(H' * OutputWeight)';                       % Y爲訓練數據輸出(列向量) 
if Elm_Type == REGRESSION 
    TrainingAccuracy=sqrt(mse(T - Y));        % 迴歸問題計算均方誤差根
end
clear H;

%% 計算測試數據的輸出(預測標籤)
start_time_test=cputime;    % 測試計時
tempH_test=InputWeight*TV.P;% 測試的輸入
clear TV.P;  

ind=ones(1,NumberofTestingData);
BiasMatrix=BiasofHiddenNeurons(:,ind); % 擴展BiasMatrix矩陣大小與H匹配 
tempH_test=tempH_test + BiasMatrix;% 加上偏差的最終隱層輸入
switch lower(ActivationFunction)
    case {'sig','sigmoid'}% Sigmoid 函數   
        H_test = 1 ./ (1 + exp(-tempH_test));
    case {'sin','sine'}   % Sine 函數 
        H_test = sin(tempH_test);        
    case {'hardlim'}      % Hardlim 函數
        H_test = hardlim(tempH_test);        
    case {'tribas'}       % Triangular basis 函數
         H_test = tribas(tempH_test);        
    case {'radbas'}       % Radial basis 函數
         H_test = radbas(tempH_test);        
    % 可在此添加更多激活函數             
end
TY=(H_test' * OutputWeight)';                       %   TY: 測試數據的輸出

end_time_test=cputime;
TestingTime=end_time_test-start_time_test;          % 計算ELM測試集時CPU花費的時間

%% 計算準確率
if Elm_Type == REGRESSION
    TestingAccuracy=sqrt(mse(TV.T - TY));           % 迴歸問題計算均方誤差根
end

% 如果是分類問題計算分類的準確率
if Elm_Type == CLASSIFIER 
    MissClassificationRate_Training=0;
    MissClassificationRate_Testing=0;
    % 計算訓練集上的分類準確率
    for i = 1 : size(T, 2) 
        [x, label_index_expected]=max(T(:,i));
        [x, label_index_actual]=max(Y(:,i));
        if label_index_actual~=label_index_expected
            MissClassificationRate_Training=MissClassificationRate_Training+1;
        end
    end
    % 計算測試集上的分類準確率
    TrainingAccuracy=1-MissClassificationRate_Training/size(T,2); % 訓練集分類正確率
    for i = 1 : size(TV.T, 2)
        [x, label_index_expected]=max(TV.T(:,i));
        [x, label_index_actual]=max(TY(:,i));
        if label_index_actual~=label_index_expected
            MissClassificationRate_Testing=MissClassificationRate_Testing+1;
        end
    end
    TestingAccuracy=1-MissClassificationRate_Testing/size(TV.T,2);  % 測試集分類正確率
end

    在以上代碼中博主已相當詳實地進行了註釋,需要注意的是程序中默認訓練和測試文件中第一列上的數據爲樣本標記,其餘所有列中數據爲樣本屬性,因此在調用該函數時應先保證自己的數據集已經整理爲正確的格式,如下面整理好的Iris數據集(第一列爲樣本標號)。

圖片展示

    至於數據集如何整理可參考博主前面的博文:UCI數據集整理(附論文常用數據集),其中提供了詳細的數據集和相關介紹。這裏我們寫一個測試函數驗證以上的ELM函數,對於分類任務我們選取經典的UCI Iris數據集,測試的代碼如下:

clear all 
clc

dataSet = load('iris.txt');                             % 載入數據集
len_dataSet = size(dataSet,1);                          % 數據集樣本數
ind = randperm(len_dataSet);                            % 隨機挑選數據
train_set = dataSet(ind(1:round(len_dataSet*0.7)),:);   % 隨機的70%數據作爲訓練集
test_set = dataSet(ind(round(len_dataSet*0.7)+1:end),:);% 隨機的30%數據作爲測試集

save iris_train.txt -ascii train_set                    % 保存訓練集爲txt文件
save iris_test.txt -ascii test_set                      % 保存測試集爲txt文件

% 調用ELM函數
[TrainingTime, TestingTime, TrainingAccuracy, TestingAccuracy] = ELM('iris_train.txt', 'iris_test.txt', 1, 20, 'sig');

% 輸出結果
fprintf('訓練集準確率:%g \n',TrainingAccuracy);
fprintf('測試集準確率:%g \n',TestingAccuracy);

運行結果如下:

圖片展示

    對於迴歸任務可選取Sinc的數據集(該數據已整理分爲訓練和測試集),測試的代碼如下:

clear all
clc

% 調用ELM函數
[TrainingTime,TestingTime,TrainingAccuracy,TestingAccuracy] = ELM('sinc_train','sinc_test',0,20,'sig');

% 輸出結果
fprintf('訓練集MSE:%g \n',TrainingAccuracy);
fprintf('測試集MSE:%g \n',TestingAccuracy);

運行結果如下:

圖片展示


下載鏈接
    若您想獲得博文中涉及的實現完整全部程序文件(包括數據集,m, txt文件等,如下圖),這裏已打包上傳至博主的CSDN下載資源中,下載後運行run_ELM1.mrun_ELM2.m文件即可運行。文件下載鏈接如下:
文件情況
下載鏈接:博文中涉及的完整程序文件


5. 結束語

    由於博主能力有限,博文中提及的方法與代碼即使經過測試,也難免會有疏漏之處。希望您能熱心指出其中的錯誤,以便下次修改時能以一個更完美更嚴謹的樣子,呈現在大家面前。同時如果有更好的實現方法也請您不吝賜教。

    大家的點贊和關注是博主最大的動力,博主所有博文中的代碼文件都可分享給您,如果您想要獲取博文中的完整代碼文件,可通過C幣或積分下載,沒有C幣或積分的朋友可在關注、點贊博文後提供郵箱,我會在第一時間發送給您。博主後面會有更多的分享,敬請關注哦!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章