本文將不使用任何人工智能框架,只用簡單的 dotnet 的類,本身搭建一我的工智能網絡。本文適合小夥伴跟着一步步寫php
特別感謝老馬的程序人生的幫助,本文有大量代碼都是從如何利用 C# 抽象神經網絡模型抄的git
在人工智能模型有不一樣的問題能夠選用不一樣的模型,本文主要寫一個 BP 網絡用於作分類,也就是寫出一個簡單的多分類人工智能和一個模擬二進制 與 計算和 或 計算。請不要認爲本文會告訴你們如何寫一個會和你聊QQ的人工智能,這裏的人工智能其實也就是一個工具,和想象的智能差距有點大。本文的人工智能只能作對數值輸入進行分類或實現模擬二進制計算github
在一我的工智能模型,能夠將人工智能模型做爲網絡模型創建,一我的工智能模型是一個網絡模型,一個網絡裏面會有不少層,每一層有不少元。本文將從小到大進行定義,先從元定義,而後再從層定義,最後定義網絡算法
在人工智能模型裏面,最小單元都是神經元。一個神經元能夠收到多個輸入,而只有一個輸出。在代碼裏面,將輸入和輸出的值都定義爲double值。而本文寫的神經元是固定輸入數量,也就是在神經元對象建立的時候須要告訴這個神經元能夠收到多少個數量的輸入數組
在神經元裏面將會對每一個輸入添加一個權值,在神經網絡的每一個元能夠收到多個輸入,而對每一個輸入須要使用不一樣的權值計算網絡
最簡單的神經元就是將每一個輸入的值乘以一個權值而後加起來而後輸出。固然稍微複雜一點的是加起來以後須要加一個閾值而後調用激活函數計算輸出數據結構
先忽略元的計算,定義元的數據結構框架
/// <summary> /// 神經元 /// </summary> public abstract class Neuron { /// <summary> /// 隨機數生成器 /// </summary> public static Random Rand { get; set; } = new Random(); /// <summary> /// 隨機數範圍 /// </summary> public static DoubleRange RandRange { get; set; } = new DoubleRange(0.0f, 1.0f); /// <summary> /// 多輸入 /// </summary> public int InputsCount { get; } /// <summary> /// 單輸出 /// </summary> public double Output { get; protected set; } = 0.0; /// <summary> /// 權值數組,每一個輸入對應一個權值,也就是 InputsCount 的數量和 Weights 元素數相同 /// </summary> /// 在神經網絡的每一個元能夠收到多個輸入,而對每一個輸入須要使用不一樣的權值計算。此時對應每一個輸入一個權值,而輸出只有一個 public double[] Weights { get; } /// <summary> /// 構造函數 /// </summary> /// <param name="inputs">表示有多少個輸入</param> protected Neuron(int inputs) { // 輸入的數量不能小於1的值 InputsCount = Math.Max(1, inputs); Weights = new double[InputsCount]; Randomize(); } /// <summary> /// 初始化權值 /// </summary> public virtual void Randomize() { for (int i = 0; i < InputsCount; i++) { // 建立在 RandRange.Max 和 RandRange.Min 範圍內的隨機數 Weights[i] = RandRange.GetRan(); } } /// <summary> /// 對當前傳入的輸入計算輸出的值 /// </summary> /// <param name="input">輸入的值的數量要求和 InputsCount 相同</param> /// <returns></returns> public abstract double Compute(double[] input); }
上面的模型感謝老馬的程序人生大佬提供,只是定義了基礎數據結構,而計算方法做爲抽象方法。在 Compute 方法接受多個輸入,而後有一個 double 輸出dom
在 Randomize 方法給了權值數組一些隨機值,其實有一句話是人工智能和隨機猜是差很少的。本文下面將寫一個隨機給權值的訓練方法ide
上面的 DoubleRange 是本身定義的,用來建立範圍內的隨機數,只須要看代碼就知道是如何作的
public class DoubleRange { public DoubleRange(double a, double b) { Max = Math.Max(a, b); Min = Math.Min(a, b); Length = Max - Min; } public double Length { get; } public double Max { get; } public double Min { get; } /// <summary> /// 返回在 Min 和 Max 的隨機數 /// </summary> /// <returns></returns> public double GetRan() { return Rand.NextDouble() * Length + Min; } /// <summary> /// 隨機數生成器 /// </summary> private static Random Rand { get; set; } = new Random(); }
接下來定義一個 ActivationNeuron 繼承 Neuron 這裏定義了閾值和激活函數
/// <summary> /// 神經元 /// </summary> public class ActivationNeuron : Neuron { /// <summary> /// 閾值 /// </summary> public double Threshold { get; set; } = 0.0; /// <summary> /// 激活函數 /// </summary> public IActivationFunction ActivationFunction { get; set; } /// <summary> /// 構造函數 /// </summary> /// <param name="inputs">輸入個數</param> /// <param name="function">激活函數</param> public ActivationNeuron(int inputs, IActivationFunction function) : base(inputs) { ActivationFunction = function; } /// <summary> /// 初始化權值閾值 /// </summary> public override void Randomize() { base.Randomize(); Threshold = RandRange.GetRan(); } /// <summary> /// 計算神經元的輸出 /// </summary> /// <param name="input"></param> /// <returns></returns> public override double Compute(double[] input) { if (input.Length != InputsCount) throw new ArgumentException("輸入向量的長度錯誤。"); double sum = 0.0; for (int i = 0; i < Weights.Length; i++) { sum += Weights[i] * input[i]; } sum += Threshold; double output = ActivationFunction.Function(sum); Output = output; return output; } }
計算的方法是將多個輸入的每一個輸入乘以權值加起來,而後加上閾值,接下來放入激活函數計算輸出。爲何須要激活函數?緣由是計算的 sum 的值須要輸出到下一層須要將 sum 的值處理,如本文須要讓每一層的輸入的值都是 1
和 0
而由於每一層的輸出會做爲下一層的輸入,因此須要將 sum 值計算爲 1
和 0
也就是經過 閾值函數 將一個值按照是否大於等於 0 分爲 1
和 0
兩個值
那麼 閾值函數 的定義是什麼,閾值就是臨界值,函數的目的是大於這個臨界值會怎麼樣,小於這個臨界值會怎麼樣。本文只是讓大於等於 0 輸出爲 1
不然輸出 0 這個值
/// <summary> /// 閾值函數 /// </summary> public class ThresholdFunction : IActivationFunction { public double Function(double x) { return (x >= 0) ? 1 : 0; } public double DerivativeX(double x) { return 0; } public double DerivativeY(double y) { return 0; } }
激活函數的定義是
/// <summary> /// 對激活函數的抽象 /// 全部與神經元一塊兒使用的激活函數,都應該實現這個接口,這些函數將神經元的輸出做爲其輸入加權和的函數來計算 /// </summary> public interface IActivationFunction { /// <summary> /// 激活函數 /// </summary> /// <param name="x"></param> /// <returns></returns> double Function(double x); /// <summary> /// 求x點的導數 /// </summary> /// <param name="x"></param> /// <returns></returns> double DerivativeX(double x); /// <summary> /// 求y點的導數 /// </summary> /// <param name="y"></param> /// <returns></returns> double DerivativeY(double y); }
和 閾值函數 相應的激活函數還有符號函數等,符號函數就是按照必定的範圍,將輸入轉換爲 1
或 -1
由於負數叫符號,這也就是符號函數的名字。詳細定義請看 符號函數
/// <summary> /// 符號函數 /// </summary> public class SignFunction : IActivationFunction { public double Function(double x) { return x >= 0 ? 1 : -1; } public double DerivativeX(double x) { return 0; } public double DerivativeY(double y) { return 0; } }
也就是在接受多個輸入,對多個輸入乘以權值加起來,再加上閾值,放入激活函數,計算後輸出。這就是最簡單的元的定義
定義完成了元,接下來就是定義層的概念,每一層能夠有多個元,每一層能夠收到上一層的數據。而第一層叫輸入層,輸入層將會接受用戶的輸入。最後一層叫輸出層,輸出層的值將會做爲輸出。簡單的網絡只須要一個元,一個元做爲一層,而這個網絡也只有一層。這一層只有一個元,是輸入層也是輸出層。這是最簡單的模型,也就是本文接下來告訴你們的模型。也就是元是核心,只有一個元也能作出人工智能
/// <summary> /// 對神經網絡層的抽象,表明該層神經元的集合 /// </summary> public abstract class Layer { /// <summary> /// 該層神經元的個數 /// </summary> protected int NeuronsCount { set; get; } /// <summary> /// 該層的輸入個數 /// </summary> public int InputsCount { get; } /// <summary> /// 該層神經元的集合 /// </summary> public Neuron[] Neurons { get; } /// <summary> /// 該層的輸出向量 /// </summary> public double[] Output { get; protected set; } /// <summary> /// 神經網絡層的抽象 /// </summary> /// <param name="neuronsCount">該層神經元的個數</param> /// <param name="inputsCount">該層的輸入個數</param> /// 每一層的神經元的個數不一樣,同一層的每一個神經元的輸入個數相同,但不一樣層的輸入個數能夠不一樣 protected Layer(int neuronsCount, int inputsCount) { InputsCount = Math.Max(1, inputsCount); NeuronsCount = Math.Max(1, neuronsCount); Neurons = new Neuron[NeuronsCount]; } /// <summary> /// 計算該層的輸出向量 /// </summary> /// <param name="input"></param> /// <returns></returns> public virtual double[] Compute(double[] input) { double[] output = new double[NeuronsCount]; for (int i = 0; i < Neurons.Length; i++) { output[i] = Neurons[i].Compute(input); } Output = output; return output; } /// <summary> /// 初始化該層神經元的權值 /// </summary> public virtual void Randomize() { foreach (Neuron neuron in Neurons) { neuron.Randomize(); } } }
定義的層須要知道輸入的數量,而層裏每一個元都會被定義相同數量的輸入。此時的鏈接和全鏈接差很少,也就是每一個元都接受到相同的輸入。其實這樣的定義對於某個元只須要特定的幾個輸入也是能夠實現的,由於每一個元會對每一個輸入一個權值,若是設置某個輸入的權值爲 0 那麼至關於放棄這個輸入。這樣就能夠作到某個元只接受特定的幾個輸入而不是收到全部的輸入。而爲何一些高級的模型不會讓同一層的全部元收到的輸入相同?剛纔不是說可讓元本身控制放棄哪些輸入,緣由是雖然可讓元本身控制放棄一些輸入,可是這樣作的效率比較低,高級的模型須要提高效率,本文這裏無視因此可使用每一層的全部元收到相同的輸入
本文這裏使用的模型是每一層的元數量不可變,在定義層時就知道這一層有多少元。這樣的模型功能會比較差,可是做爲入門的博客,這樣的定義差很少可使用了
每個元只有一個輸入,因此每一層的輸出數量和每一層的元數量相同
接下來定義 ActivationLayer 做爲實際的神經網絡層,其實從代碼能夠看到連個類能夠合併在一塊兒,只是老馬的程序人生大佬做爲兩篇博客,我這裏也就跟着他寫了
public class ActivationLayer : Layer { /// <summary> /// 神經網絡層 /// </summary> /// <param name="neuronsCount">該層神經元的個數</param> /// <param name="inputsCount">該層的輸入個數</param> /// <param name="function">激活函數</param> public ActivationLayer(int neuronsCount, int inputsCount, IActivationFunction function) : base(neuronsCount, inputsCount) { for (int i = 0; i < Neurons.Length; i++) Neurons[i] = new ActivationNeuron(inputsCount, function); } }
一個網絡能夠包含多個層
/// <summary> /// 對神經網絡結構的抽象,它表示神經元層的集合 /// </summary> public abstract class Network { /// <summary> /// 網絡層的個數 /// </summary> protected int LayersCount { set; get; } /// <summary> /// 網絡輸入的個數 /// </summary> public int InputsCount { get; } /// <summary> /// 構成網絡的層 /// </summary> public Layer[] Layers { get; } /// <summary> /// 網絡的輸出向量 /// </summary> public double[] Output { get; protected set; } /// <summary> /// 構造函數 /// </summary> /// <param name="inputsCount">輸入的個數</param> /// <param name="layersCount">網絡層的數量</param> protected Network(int inputsCount, int layersCount) { InputsCount = Math.Max(1, inputsCount); LayersCount = Math.Max(1, layersCount); Layers = new Layer[LayersCount]; } /// <summary> /// 計算網絡的輸出 /// </summary> /// <param name="input">要求輸入的元素數和 InputsCount 網絡輸入的個數相同</param> /// <returns></returns> public virtual double[] Compute(double[] input) { // 第一層用於輸入,將輸入層做爲傳輸 double[] courier = input; for (int i = 0; i < Layers.Length; i++) { // 第一層的輸出做爲第二層的輸入 // 因此輸入是 courier 而返回的輸出做爲下一層的輸入 courier = Layers[i].Compute(courier); } // 最後一層的輸出做爲網絡輸出 Output = courier; return courier; } /// <summary> /// 初始化整個網絡的權值 /// </summary> public virtual void Randomize() { foreach (Layer layer in Layers) { layer.Randomize(); } } }
請看一下代碼裏面的註釋
實現一個網絡
public class ActivationNetwork : Network { /// <summary> /// 神經網絡 /// </summary> /// <param name="function"></param> /// <param name="inputsCount"></param> /// <param name="neuronsCount">指定神經網絡每層中的神經元數量</param> public ActivationNetwork(IActivationFunction function, int inputsCount, params int[] neuronsCount) : base(inputsCount, neuronsCount.Length) { // neuronsCount 指定神經網絡每層中的神經元數量。 for (int i = 0; i < Layers.Length; i++) { Layers[i] = new ActivationLayer(neuronsCount[i], // 每一個神經元只有一個輸出,上一層有多少個神經元也就有多少個輸出,也就是這一層須要有多少輸入 (i == 0) ? inputsCount : neuronsCount[i - 1], function); } }
上面代碼的實現有些詭異,緣由是個人蔘數沒有寫好。在 ActivationNetwork 的最後一個參數是一個數組,指定神經網絡每層中的神經元數量。也就是輸入 [1,2]
表示有兩層,其中第一層有 1 個神經元,第二層有兩個。輸入 [1,1,5]
表示有三層
而根據人工智能的教程,第一層是輸入層,也就是 i == 0
設置這一層的輸入爲用戶輸入數量。從第二層開始,每一層的輸入數量爲上一層的輸出數量
定義完成了人工智能模型,一個模型不會自動運行,還須要定義一個訓練方法。做爲能夠本身學習的人工智能,學習方法能夠分爲監督學習和無監督學習,在代碼裏面我用老馬的程序人生的說法非監督學習。這兩個方法的不一樣在於監督學習是我知道輸入內容和結果,我將輸入放入模型,對比模型輸出的值和我知道的結果,按照模型輸出的值和我知道的結果的偏差反饋給模型,讓模型修改參數,如修改權值參數。而無監督學習是我也不知道結果,這個比較難理解,詳細請看監督學習和無監督學習 或 小白都看得懂的監督學習與無監督學習 本文用到的是監督學習
/// <summary> /// 對監督學習的抽象,這個接口描述了全部監督學習算法應該實現的方法 /// </summary> /// 監督學習和下面的非監督學習的不一樣在於,非監督學習只須要給輸入,不須要給輸出,也就是在訓練是不須要知道結果,而監督學習是須要知道輸入是什麼對應輸出是什麼,相對於非監督學習,理解監督學習比較簡單 public interface ISupervisedLearning { /// <summary> /// 單樣本訓練 /// </summary> /// <param name="input"></param> /// <param name="output"></param> /// <returns></returns> double Run(double[] input, double[] output); /// <summary> /// 多樣本訓練 /// </summary> /// <param name="input"></param> /// <param name="output"></param> /// <returns></returns> double RunEpoch(double[][] input, double[][] output); }
這裏的輸入分爲多樣本訓練和單樣本訓練也就是我給一堆數據就是多樣本訓練。從方法參數能夠看到輸入的都是二維數組,固然這裏說二維數組是不對的,應該是數組的數組。從單樣本訓練方法能夠看到每一個數據都是輸入是一個 double 數組,而輸出也是一個 double 數組,那麼多個輸入和多個輸出就是數組的數組
剛纔也有說到,人工智能和隨機猜是同樣的,在人工智能的訓練很重要的是反饋,也就是我告訴人工智能說算錯了,他應該如何修改參數?這部分看起來有點難,假設這我的工智能我告訴他算錯了,他就隨機修改他的參數,這就是本文的 Slow 訓練方法,這個方法只是慢,和其餘訓練方法差很少
public class SlowLearning : ISupervisedLearning { public SlowLearning(ActivationNetwork network) { _network = network; } /// <summary> /// 神經網絡 /// </summary> private readonly ActivationNetwork _network; private DoubleRange RandRange { get; } = new DoubleRange(-1.0, 1.0); /// <summary> /// 單個訓練樣本 /// </summary> /// <param name="input"></param> /// <param name="output"></param> /// <returns></returns> public double Run(double[] input, double[] output) { double[] networkOutput = _network.Compute(input); Layer layer = _network.Layers[0]; // 統計總偏差 double error = 0.0; for (int j = 0; j < layer.Neurons.Length; j++) { // 偏差值,用已知的值減去網絡計算出來的值 double e = output[j] - networkOutput[j]; // 若是存在偏差,那麼更新權重 if (e != 0) { ActivationNeuron perceptron = (ActivationNeuron)layer.Neurons[j]; for (int i = 0; i < perceptron.Weights.Length; i++) { perceptron.Weights[i] = RandRange.GetRan(); } perceptron.Threshold = RandRange.GetRan(); error += Math.Abs(e); } } return error; } /// <summary> /// 訓練全部樣本 /// </summary> /// <param name="input"></param> /// <param name="output"></param> /// <returns></returns> public double RunEpoch(double[][] input, double[][] output) { double error = 0.0; for (int i = 0, n = input.Length; i < n; i++) { error += Run(input[i], output[i]); } return error; } }
用隨機修改參數方法要求模型很簡單,本文要求的模型只是一層,也就是輸入層和輸出層是相同的一層。在 單個訓練樣本 方法將會使用模型計算出一個值,經過 double[] networkOutput = _network.Compute(input);
而後對比偏差 double e = output[j] - networkOutput[j];
若是存在偏差,那麼用 perceptron.Weights[i] = RandRange.GetRan();
更新每一個元的每一個參數的值
如今大概寫完了代碼,本文的代碼放在 github 下載用 VisualStudio 打開 Bp.sln 文件,而後按下 F5 就能夠運行
嘗試用這個模型和訓練方法作出一個模擬二進制 與 的計算,也就是輸入有兩個,輸出是件這兩個輸入進行 與 運算
0,0 = 0 0,1 = 0 1,0 = 0 1,1 = 1
定義輸入數據
double[][] inputs = new double[4][]; double[][] outputs = new double[4][]; //(0,0);(0,1);(1,0) inputs[0] = new double[] { 0, 0 }; inputs[1] = new double[] { 0, 1 }; inputs[2] = new double[] { 1, 0 }; outputs[0] = new double[] { 0 }; outputs[1] = new double[] { 0 }; outputs[2] = new double[] { 0 }; //(1,1) inputs[3] = new double[] { 1, 1 }; outputs[3] = new double[] { 1 };
建立人工智能網絡
ActivationNetwork network = new ActivationNetwork( new ThresholdFunction(), 2, 1);
這我的工智能網絡使用輸入層有兩個,只有一層網絡,一層網絡裏面只有一個神經元
建立訓練方法
SlowLearning teacher = new SlowLearning(network);
而後用訓練方法訓練模型
int iteration = 1; while (true) { double error = teacher.RunEpoch(inputs, outputs); Console.WriteLine($"迭代次數:{iteration},整體偏差:{error} "); if (error == 0) break; iteration++; }
嘗試運行代碼,能夠看到我沒有告訴人工智能如何作 與 運算,可是人工智能模擬了方法
嘗試訓練人工智能模型模擬二進制或計算
double[][] inputs = new double[4][]; double[][] outputs = new double[4][]; //(0,0) inputs[0] = new double[] { 0, 0 }; outputs[0] = new double[] { 0 }; //(1,1);(0,1);(1,0) inputs[1] = new double[] { 0, 1 }; inputs[2] = new double[] { 1, 0 }; inputs[3] = new double[] { 1, 1 }; outputs[1] = new double[] { 1 }; outputs[2] = new double[] { 1 }; outputs[3] = new double[] { 1 }; ActivationNetwork network = new ActivationNetwork(new ThresholdFunction(), 2, 1); SlowLearning teacher = new SlowLearning(network); int iteration = 1; while (true) { double error = teacher.RunEpoch(inputs, outputs); Console.WriteLine(@"迭代次數:{0},整體偏差:{1}", iteration, error); if (error == 0) break; iteration++; }
我沒有告訴人工智能或計算的方法,可是人工智能能夠訓練如何計算
這樣的太簡單了,其實上面的模型能夠作出多分類,多分類就是將一些輸入分爲幾類。如按照二維幾何距離將數據分爲幾類,而後讓人工智能分類
double[][] inputs = new double[15][]; double[][] outputs = new double[15][]; //(0.1,0.1);(0.2,0.3);(0.3,0.4);(0.1,0.3);(0.2,0.5) inputs[0] = new double[] { 0.1, 0.1 }; inputs[1] = new double[] { 0.2, 0.3 }; inputs[2] = new double[] { 0.3, 0.4 }; inputs[3] = new double[] { 0.1, 0.3 }; inputs[4] = new double[] { 0.2, 0.5 }; outputs[0] = new double[] { 1, 0, 0 }; outputs[1] = new double[] { 1, 0, 0 }; outputs[2] = new double[] { 1, 0, 0 }; outputs[3] = new double[] { 1, 0, 0 }; outputs[4] = new double[] { 1, 0, 0 }; //(0.1,1.0);(0.2,1.1);(0.3,0.9);(0.4,0.8);(0.2,0.9) inputs[5] = new double[] { 0.1, 1.0 }; inputs[6] = new double[] { 0.2, 1.1 }; inputs[7] = new double[] { 0.3, 0.9 }; inputs[8] = new double[] { 0.4, 0.8 }; inputs[9] = new double[] { 0.2, 0.9 }; outputs[5] = new double[] { 0, 1, 0 }; outputs[6] = new double[] { 0, 1, 0 }; outputs[7] = new double[] { 0, 1, 0 }; outputs[8] = new double[] { 0, 1, 0 }; outputs[9] = new double[] { 0, 1, 0 }; //(1.0,0.4);(0.9,0.5);(0.8,0.6);(0.9,0.4);(1.0,0.5) inputs[10] = new double[] { 1.0, 0.4 }; inputs[11] = new double[] { 0.9, 0.5 }; inputs[12] = new double[] { 0.8, 0.6 }; inputs[13] = new double[] { 0.9, 0.4 }; inputs[14] = new double[] { 1.0, 0.5 }; outputs[10] = new double[] { 0, 0, 1 }; outputs[11] = new double[] { 0, 0, 1 }; outputs[12] = new double[] { 0, 0, 1 }; outputs[13] = new double[] { 0, 0, 1 }; outputs[14] = new double[] { 0, 0, 1 };
這個數據是如何利用 C# 實現神經網絡的感知器模型用到的數據
上面的數據的輸入是兩個數,而輸出是三個數。在輸出用 0 和 1 表示屬於哪一個類型
本文定義一層網絡,在這一層網絡的輸出須要三個數也就是須要三個元
ActivationNetwork network = new ActivationNetwork(new ThresholdFunction(), 2, 3);
和上面代碼同樣嘗試讓模型學習
SlowLearning teacher = new SlowLearning(network); int iteration = 1; while (true) { double error = teacher.RunEpoch(inputs, outputs); Console.WriteLine(@"迭代次數:{0},整體偏差:{1}", iteration, error); if (error == 0) break; iteration++; }
這就是一個簡單的人工智能模型,全部代碼都沒有用到現有人工智能框架,都是使用 dotnet 基礎的代碼。用 C# 實現人工智能模型最成熟的是 ML.NET 可是這個庫沒有基礎很難知道是作什麼
本文的代碼放在github 歡迎小夥伴訪問
其實人工智能的一個核心是訓練算法,本文告訴你們的是 Slow 算法,這個算法就是在人工智能模型輸出的值和我知道的值不一樣時,讓模型隨機更新參數。這個作法雖然能完成,可是效率很低,特別在元的數量多的時候。此時就須要用到比較高級的訓練方法,如如何利用 C# 實現神經網絡的感知器模型
特別感謝老馬的程序人生提供的模型
我搭建了本身的博客 https://blog.lindexi.com/ 歡迎你們訪問,裏面有不少新的博客。只有在我看到博客寫成熟以後纔會放在csdn或博客園,可是一旦發佈了就再也不更新
若是在博客看到有任何不懂的,歡迎交流,我搭建了 dotnet 職業技術學院 歡迎你們加入
本做品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。歡迎轉載、使用、從新發布,但務必保留文章署名林德熙,不得用於商業目的,基於本文修改後的做品務必以相同的許可發佈。若有任何疑問,請與我聯繫。