2020q3 課程專題 (quiz9) === contributed by < `jeremy3951` > :::success 綠色代表延伸問題題目 ::: :::info 藍色代表引用文章的話 ::: :::danger 紅色代表觀念更正 ::: ###### tags: `(2020q3)進階電腦系統理論與實作` # 題目1: :::success 延伸問題 : 1. 解釋解釋程式碼運作原理,應補充相關背景知識 ::: ## 1. 解釋解釋程式碼運作原理,應補充相關背景知識 ### 補充相關背景知識 __問題 1 : sigmoid function 是對應神經元中的什麼行為 ?__ 引用[參考網站](https://zrn-coding.github.io/2020/03/21/AI-2/)的一段話 : :::info 激活函數模擬神經傳導的運作方式,當輸出神經元接受刺激的總和 經過激活函式的運算大於臨界值時,會傳遞至下一個神經元。 ::: sigmoid function 就是模擬神經元中訊號是否傳遞的機制(開關) __問題 2 : 為什麼要挑 sigmoid 當作 activate function ?__ 參考資料: [Neural Networks (一)](https://chtseng.wordpress.com/2017/07/24/neural-networks-%E4%B8%80/) 若要模仿神經元中全開全關的模式用一般的 step function 比較好,可是 step function 不可微分不能用 gradient descent 來算梯度。 基於下列原因讓大部分的類神經網路都用 sigmoid function 作為該神經網路的激勵函數 : 1. Sigmoid 的回傳值是連續且可微分的 2. 與 step function 不同,它的值是漸近 __問題 3 : 為什麼要使用 back-propagation ?__ (跟 gradient descent 有關) 首先要提到 gradient descent : 1. 為何要使用 gradient descent ? 根據台大李宏毅教授的[影片](https://www.youtube.com/watch?v=fegAeph9UaA)中有提到 gradient descent 只要是針對可以微分的 function ,此方法都可以把 funciotn set 中誤差最小的那個 funciton 找出來(局部或全域) 2. gradient descent 的目的 ? 將 function set 中 loss 最低的 funciotn 找出來 3. gradient descent 怎麼做 ? 假設一 loss function , L(w),w 是一個 function set 裡面的 function ,現在要求 L(w) 的最小值。 在這種情況下需要一個參數叫做 learning rate ,有了 learning rate 後就可以把它跟 $\partial L(w)/\partial w$ 相乘,$\partial L(w)/\partial w$ 為梯度,利用 learning rate 不斷去更新梯度最後得到最小值 (參考)[【Python】淺談梯度下降與實作(上):為什麼是你](https://dotblogs.com.tw/shaynling/2019/09/10/142936) (參考台大李宏毅教授的影片)[ML Lecture 7: Backpropagation](https://www.youtube.com/watch?v=ibJpTrp5mcE&t=1279s) backpropagation 利用 chain rule 加速 gradient descent 的運算,提升神經網路的學習速度 ### 解釋程式碼運作原理 __初始化神經網路__ : `ann_init` ann_init(3,1,2,1) : New network with 3 inputs, 1 hidden layer of 2 neurons , and 1 output. 神經網路圖如下 : ```graphviz digraph a{ rankdir = "LR" node[shape = circle] A B C D E F A->D A->E B->D B->E C->D C->E D->F E->F } ``` 記憶體配置以及指標指向的記憶體位址 : ![](https://i.imgur.com/UsbOVH5.jpg) `ret->weight = (double *) ((char *) ret + sizeof(ann_t));` 右邊的括號已經寫好了要指向的記憶體位置了,那為什麼需要 (double *)? (將其拿掉也可以正常執行程式碼) Ans : 不加也可以,這樣寫是為了方便觀察。 權重初始化 : `ann_randomize(ret);` ```cpp= void ann_randomize(ann_t *ctx) { for (int i = 0; i < ctx->total_weights; ++i) ctx->weight[i] = ANN_RAND() - 0.5; /* weights from -0.5 to 0.5 */ } ``` total_weight 裡面不只有神經元運算需要的 weight ,連運算完要加上的 bias 也順便放在這個變數裡面了 (`double sum = *w++ * (-1.0);`就是先算 bias) 探討 activation 加速 : 連續轉離散... __訓練神經網路__ : ```cpp= do { ++count; if (count % LOOP_MAX == 0) { ann_randomize(ctx); last_err = LOOP_MAX; } ann_t *save = ann_copy(ctx); /* Take a random guess at the ANN weights. */ for (int i = 0; i < ctx->total_weights; ++i) ctx->weight[i] += RRR; err = 0; for (int k = 0; k < (1 << 3); k++) err += pow(*ann_run(ctx, input[k]) - output[k], 2.0); /* Keep these weights if they're an improvement. */ if (err < last_err) { ann_free(save); last_err = err; } else { ann_free(ctx); ctx = save; } } while (err > 0.01); ``` 第 3~6 行設定執行次數 ,次數內沒完成任務就重來 第 11~12 初始化權重 第 15~16 計算 error 第 19~24 行 => ( 當下 model 是否為 best model )? 保存 : 棄掉 該神經網路透過將權重隨機更新並且計算 error 來保留最佳 model :::success 延伸問題 : 2. 使用 back-propagation (反向傳遞) 改寫上述程式碼,並探討激勵函數的特性 ::: ## 2.使用 back-propagation (反向傳遞) 改寫上述程式碼,並探討激勵函數的特性 針對此次的類神經網路 (ann_init(3,1,2,1)),總共有 11 個 weights 且 loss function 為 MSE , 若用 gradient descent ,就要把 MSE 對 11個 weights 個別做偏微分。 若想加速運算,可以使用 back-propagation ... $\ell(x, y) = L = \{l_1,\dots,l_N\}^\top, \quad l_n = - w_n \left[ y_n \cdot \log \sigma(x_n) + (1 - y_n) \cdot \log (1 - \sigma(x_n)) \right],$ $\ell(x, y) = \begin{cases} \operatorname{mean}(L), & \text{if reduction} = \text{`mean';}\\ \operatorname{sum}(L), & \text{if reduction} = \text{`sum'.} \end{cases}$ 這次的實作參考的文章 : [專為程式人寫的神經網路導論 (以反傳遞演算法為入門磚)](https://www.slideshare.net/ccckmit/ss-93434977) 將要偏微分的函式分別 __"拆分成一個個的運算單元"__,然後再從結果開始算梯度逆推回去 舉例 :  ![](https://i.imgur.com/TOnsjcn.jpg) 將 x 拆分成 x、x1、x2 後,再從結果逆推得到各個變數的梯度(d->c->a、b) ![](https://i.imgur.com/pprDGJE.jpg) 然後利用剛剛得到的梯度再乘上 learning rate 來更新權重。 回到題目, 本次要更新的權重有 11 個,所以在算 bp 的時候,只要算這 11 個和 output 的梯度再不斷的更新權重就可以了。 初步結果 : 網路架構如同此次題目 ann_init(3,1,2,1) input = { 1 , 0 , 1 } , output = 1.0 , train 50 次 ![](https://i.imgur.com/rQYg1lg.png) w11 是 output , learning rate = 0.01,可以看到經過 R1 後 output 從 0.48 變成 0.49 ,更接近 output(=1) 了,不過 learning rate 太小所以更新的幅度就會比較小, ![](https://i.imgur.com/IOdXkPT.png) 透過調整 lr 成 0.1 : ![](https://i.imgur.com/nmcWByZ.png) 很明顯地更接近 output 了 __但是!!__ 如果將 input 多組放入神經網路用上述的方式 training 會很難 train ![](https://i.imgur.com/43s7ydr.png) train 完 10000個 epoch 值依然還差很遠... 後來我試著把此神經網路當作分類問題, output 因為 sigmoid function 的關係值域在 0 到 1 之間,我讓 output >=0.5 就變成 1 反之變成 0。 ![](https://i.imgur.com/RsYARtN.png) 雖然過了 1000 epoch 還是沒能訓練完畢,感覺繼續訓練下去就能 train 出來了 實作結果 : 當初以為再多幾個 epoch 訓練就能得到正確解答了。不過,事情出乎我意料之外,我就算訓練 40 萬個 epoch 也訓練不出正確解答... 可能的原因 : 在 training 的過程中每組 input 得到的 output 值都很接近,但是每組 input 對應的答案卻是截然不同的 (0 和 1),當某個 predict 錯誤而導致梯度算出來後正準備要更新 weight 的時候,其他的 weight 也會因為這個 gradient 變動而跟著更新,這也導致有些原本是對的 output 因為別組答錯,它也更著被更新,原本就答對了,更新完後又錯了,一直這樣循環下去,所以才遲遲訓練不出精準的答案 程式碼如下 : https://github.com/jeremy3951/NeronNetwork/blob/main/bp-2 流程說明 : 1. input 進入 NN 後透過一系列的前饋計算得到 output 2. 將 loss function 對每個 weight 的偏微分算出來得到 gradient 3. w[i] -= lr * gradient[i] 4. 再回到第一步... check : - [x] 在 forward 的時候 output 是否一直都是正確的 ? - [x] 確認 loss function 的偏微分公式推導是否正確 ? - [x] 確認在 backward 的時候梯度是否都計算正確 ? - [x] 確認 weight 是否都正確地被更新了 ? 理解到本題是分類問題,用 MSE 來當 loss function 不適合 [为什么均方差(MSE)不适合分类问题?](https://blog.csdn.net/weixin_41888969/article/details/89450163) 解釋 : 梯度會互相消掉 ! ! 用 binary cross entropy 當 loss function 就沒事了嗎? 神經網路(3,1,2,1)本身有什麼問題? ( underfitting ) [零基礎自學深度學習 :(二)優化神經網路](https://medium.com/@evan_hsiao/%E9%9B%B6%E5%9F%BA%E7%A4%8E%E8%87%AA%E5%AD%B8%E6%B7%B1%E5%BA%A6%E5%AD%B8%E7%BF%92-%E4%BA%8C-%E5%84%AA%E5%8C%96%E7%A5%9E%E7%B6%93%E7%B6%B2%E8%B7%AF-d2c71b5df7e5) 加深網路就好了嗎 ? (梯度消失問題) 回到前面的疑問,到底是網路深度問題還是 loss function 的問題? 若 loss function 實作沒問題的話,那這題就不是 loss function 選用的問題 實作觀察到的點 : 如果把資料改成5筆以下就可以 train 得出來 (underfitting) :::success 延伸問題 : 3. 引入其他激勵函數,例如 ReLU,並確保針對其特性進行適度初始化 ::: ## 3.引入其他激勵函數,例如 ReLU,並確保針對其特性進行適度初始化 若將原本的程式碼的 sigmoid function 直接改成 ReLU 執行所需要的迴圈數會變得非常多 ... # 題目2: ## 1.解釋程式碼運作原理,指出上述實作的缺失並改進 觀察這兩個 function, solve function 回傳 bool 值 solveSudoku 不回傳值,且在最後一行呼叫 solve 由上述觀察可得知 solve 會一直被呼叫,而 solveSudoku 執行完就結束了 為了先了解該程式的架構,所以先從 solveSudoku 開始解讀 ! ### 函式 solveSudoku 的理解 先看第一個迴圈 : ```cpp for (int i = 0; i < 9; ++i) m.row[i] = m.col[i] = m.box[i] = ALL_NUMS; ``` 還有定義 : ```cpp const uint16_t ALL_NUMS = 0x1FF; ``` ALL_NUMS 裡面裝著 9 個 1 並且分別賦值給 row 、 col 、 box 第一個二層迴圈 : ```cpp= for (int y = 0; y < 9; ++y) { for (int x = 0; x < 9; ++x) { if (board[y][x] != '.') { int n = board[y][x] - '1'; uint16_t k = ~(1U << n); m.row[x] &= k, m.col[y] &= k, m.box[bi(x, y)] &= k; } } } ``` 觀察這個迴圈裡面的操作,第 4、5、6 行一起看 : board 裡面的字元減掉 '1' 得到這個字元所代表的數字,然後將這個數字用 bitwise 的方式來表示 (9個都是1的bits中,把該數字的位置改成0) 然後將此 k 值分別寫入 row 、 col 、 box 中 __執行完第一個二層迴圈後可以知道每個 row、column、box 裡面有什麼數字 !__ 接下來觀察第二個二層迴圈 : ```cpp= for (int y = 0; y < 9; ++y) { for (int x = 0; x < 9; ++x) { if (board[y][x] != '.') continue; uint16_t mask = m.row[x] & m.col[y] & m.box[bi(x, y)]; if ((mask & (MMM)) == 0) { size_t n = __builtin_ctz(mask); uint16_t k = ~(1U << n); board[y][x] = '1' + n; m.row[x] &= k, m.col[y] &= k, m.box[bi(x, y)] &= k; } } } ``` 由第 3 、 4 行可以知道這個二層迴圈是針對空白的格子。 接下來的第 5 行用畫圖比較好理解, |.|1|2| |-|-|-| |3|4|5| |7|8|9| 假設 (x,y)=(0,0) `mask = row[0]&col[0]&box[0]` 此行蒐集了 row[0]、col[0]、box[0] 裡面的所有數字,也就是說 __mask 裡面的數字是空格不能填的數字!__ 接下來先跳過 if 的條件敘述看裡面的程式行為 : 第 7 行的 n 裡面裝的是 __空格可以填的最小數字!__ 第 8、9 行則是把最小數字填進去 board 裡面 第 10 行則是把這個最小數字寫入 row、col、box 裡面 後來回頭想了一下,如果照我上面說的,每個空格都用可以填的最小數字來填,那這個數獨填到一半就填不下去了! 後來我發現,數獨只有一種狀況不用分析就可以直接填數字進去,那就是只剩一個數字還沒填的時候! __為了方便我簡稱為 trivial 數__ ``` trivial 數舉例 : |.|1|2| |3|4|5| |7|8|9| or |.|1|2|3|4|5|7|8|9| 此時 trival 數為 6 (只剩一個可以填) ``` 已知 mask 裡面放著空格不能填的數字,如果這個空格有 trvial 數,則 mask 裡面的 bits 只有一個 bit 是 1 ! 所以 if 裡面的 MMM 應該要填 __( e ) mask - 1__ (選項 b 在 mask = 00...01 的時候會出錯) :::danger 認知修正 : 前面對第 7~9 行的理解不準確應修改為以下 第 7 行的 n 裡面裝的是 __trvial 數__ 第 8、9 行則是把 __trvial 數__ 填進去 board 裡面 第 10 行則是把 __trvial 數__ 寫入 row、col、box 裡面 ::: __所以第二個二層迴圈用途是把 trivial 數給填上去而已__ 在填完所有 trivial 數就要真正解 board 了 (solve function) ### 函式 solve 的理解 ```cpp= static bool solve(char **board, masks_t *m) { for (int y = 0; y < 9; ++y) { for (int x = 0; x < 9; ++x) { if (board[y][x] != '.') continue; int b = bi(x, y); uint16_t posmoves = m->row[x] & m->col[y] & m->box[b]; while (posmoves) { size_t n = __builtin_ctz(posmoves); uint16_t k = ~(1U << n); board[y][x] = '1' + n; m->row[x] &= k, m->col[y] &= k, m->box[b] &= k; if (solve(board, m)) return true; m->row[x] |= ~k, m->col[y] |= ~k, m->box[b] |= ~k; posmoves &= MOVE; } board[y][x] = '.'; return false; } } return true; } ``` solve 的引數有 : board : input masks_t : 每(行、列、box)裡面已有的數字,用 bitwise 的方式表示,0代表空格不能填的數字,1代表空格可以填的數字 第 9 行的 posmoves 裡面裝著該空格可以填的數字 由第 10、20、21 行的行為(當 posmoves == 0 時,把該空格歸零,並且回傳錯誤)可以得知當 posmoves == 0 時, __一定有其他格子填錯!__ 第 11、12、13 這個形式前面有分析過了,其程式行為就是 __"把能填的數字中最小的數字填進 board 中"__ 第 14 行把已填的數字放進 board 中 第 15、16 行繼續迭代下去來看看這次的數字是否填錯 第 17 行代表該數字填錯了,把 m 還原成填此數字前的狀態 第 18 行,如果執行到此行,代表當下的填字造成後面填字錯誤 舉例 : (狀態為填完2後準備往下填)(填字順序為 2->3->4->5->6) |.|6(guess)|5(g)|4(g)|3(g)|2(g)|7(input)|8(in)|9(in)| |-|-|-|-|-|-|-|-|-| |1(input)| 空格(0,0) ,此時 posmoves = 000000000 , `posmoves &= posmoves-1` , posmoves = 0, return false -> 空格 (0,1) ,此時 posmoves = 000100000 , `posmoves &= posmoves-1` , posmoves = 0 , return false -> 空格 (0,2) ,此時 posmoves = 000110000 , `posmoves &= posmoves-1` , posmoves = 000100000 , __posmoves != 0 , 換個值填入繼續迭代下去 !__ 此時狀態為 : |.|.|6(new)|4(g)|3(g)|2(g)|7(input)|8(in)|9(in)| |-|-|-|-|-|-|-|-|-| |1(input)| 然後繼續迭代到解出來為止... 缺失 : 1. column major 2. 遞迴成本大 3. solveSudoku 有兩個引數用不到 改進 : 1. 換成 row major 2. 把遞迴去掉 3. 縮小範圍 :::success 2.重寫不同於上述的程式碼,效能表現應優於上述 ::: 原本的程式碼 submit 結果 : ![](https://i.imgur.com/c7qQaNc.png)