【轉】Spark MLlib協同過濾之交替最小二乘法ALS原理與實踐
來源: https://blog.csdn.net/L_15156024189/article/details/81712519
請先閱讀leboop釋出的博文《Apache Mahout之協同過濾原理與實踐 》。
基於使用者和物品的協同過濾推薦都是建立在一個使用者-物品評分矩陣(user-item-score)展開的,其本質是利用現有資料填充矩陣的缺失項(missing entries),也就是預測評分。基於使用者的協同過濾通過該評分矩陣來度量使用者間的相似度(餘弦相似度,距離相似度,皮爾森相似度,皮爾斯曼相似度等等);然後,通過使用者間的相似度來尋找被推薦使用者u的k-最近鄰使用者{u1,u2,...,uk};最後,加權{u1,u2,...,uk}給所有物品的評分來預測u尚未評分的每個物品的評分,按預測評分從高到低得到使用者u的物品推薦列表{p1,p2,...,ph};現在如果向使用者u推薦一個物品,應當推薦p1,如果推薦兩個物品,應當推薦p1和p2,以此類推。然而,這個演算法並不能很好地適應大規模使用者和物品資料,比如亞馬遜Amazon數千萬使用者和數百萬物品的線上商城,儘管大多數使用者只評分或交易了非常少量的物品,複雜度非常低,但線上環境要求必須在極短的時間內返回結果時,實時計算預測值仍然不可行。為了在不犧牲推薦精準度的情況下在大規模電商網站應用協同過濾推薦演算法,人們想到了基於物品的協同過濾推薦,其思想與基於使用者協同過濾推薦演算法類似,只不過這裡使用的是物品間相似度。而這個可以通過離線預計算構建出一個描述所有物品兩兩間的相似度的物品相似度矩陣。在執行時,如果向使用者u推薦物品p,由於物品p的k-最近鄰{p1,p2,...,pk}已經通過離線計算好,而且這樣的物品數量一般都比較少,所以用他們預測p的評分可以線上上互動應用允許的短時間內完成。
事實上,在《Apache Mahout之協同過濾原理與實踐 》一文用到的評分矩陣中,只有一個使用者-物品沒有評分。一方面,在實際應用中,由於使用者只會評價或交易少部分物品,評分矩陣一般都非常稀疏。這種情況下的挑戰是用相對少的有效評分得到準確的預測。直接做法就是使用矩陣因子分解從評分模式中抽取出一組潛在的因子(latent factors)並通過這些因子向量描述使用者和物品。另一方面,Apache Mahout是使用MapReduce實現基於使用者和物品的協同過濾推薦演算法,我們知道,MapReduce在叢集各計算節點的迭代計算中會產生很多的磁碟檔案讀寫操作,嚴重影響了演算法的執行效率,而Spark MLlib是基於記憶體的分散式計算框架。所以接下來我們介紹Spark MLlib的協同過濾推薦演算法實現細節。
一、顯示反饋交替最小二乘法(ALS)
1、矩陣因子分解
例如某個使用者-電影/電視劇評分矩陣(m和n表示矩陣的行和列)如下:
使用者id/電視劇或電影 | 大頭兒子和小頭爸爸 | 火影忍者 | 百團大戰 | 泰坦尼克號 |
1 | 5 | 4 | ? | ? |
2 | 4 | 2 | ? | ? |
3 | 2 | 5 | 3 | ? |
4 | 1 | ? | ? |
4 |
5 | ? | ? | 5 | 3 |
我們引入電影/電視劇的4個隱藏特徵(latent factors)家庭生活,浪漫愛情,戰爭歷史,劇情曲折,當然這裡只是為了說明矩陣分解,可能還有其他隱藏特徵。
使用者對隱藏特徵的偏好矩陣如下:
使用者id/隱藏因子 | 家庭生活 | 浪漫愛情 | 戰爭歷史 | 劇情曲折 |
1 | 5 | 1 | 2 | 4 |
2 | 4 | 1 | 2 | 2 |
3 | 2 | 2 | 3 | 5 |
4 | 1 | 4 | 1 | 3 |
5 | 2 | 3 | 5 | 3 |
矩陣描述了每個使用者對這些隱藏因子的偏好程度,第i個使用者的特徵向量記作,是第i個使用者對第q個隱藏因子的偏好,比如=(5,1,2,4);
電影/電視劇包含隱藏特徵的程度矩陣如下:
電視劇或電影/隱藏因子 | 家庭生活 | 浪漫愛情 | 戰爭歷史 | 劇情曲折 |
大頭兒子和小頭爸爸 | 1 | 0 | 0 | 0 |
火影忍者 | 0 | 0 | 0 | 1 |
百團大戰 | 0 | 0 | 1 | 0 |
泰坦尼克號 | 0 | 1 | 0 | 0 |
矩陣描述了電影/電視劇包含隱藏特徵的程度,第j個物品的特徵向量記作,其中是第j個物品包含隱藏因子q的程度,比如=(1,0,0,0)。
從上面的這些矩陣我們可以看到,使用者1喜歡家庭生活更多,而電視劇《大頭兒子和小頭爸爸》包含家庭生活特徵,所以使用者1給這部電視劇的評5分也很高。所以我們可以做如下假設,矩陣是低秩的(隱藏因子數目k遠遠小於m和n),使用者-物品評分矩陣可以近似等於使用者特徵矩陣與電影特徵矩陣的乘積,如下:
k<<m,n
這種假設是合理的,例如某使用者偏好碳酸飲料,而百世可樂、可口可樂、芬達都是含碳酸比較多的飲料,所以可以推斷該使用者偏好這些飲料。這裡碳酸飲料就是一個隱藏因子。所以預測矩陣的缺失項就變成了求解和。
2、交替最小二乘法(ALS)數學推導
leboop在百度檢視了很多關於ALS演算法公式的推導,基本都是直接給定結果,但是結果卻是錯誤的,所以這裡有必要作為糾正再詳細推導一遍。
滿足 k<<m,n條件的和有很多,究竟哪個才是最優的?當然使等式成立的肯定是最好的,然而由於推薦系統中資料量非常大且計算複雜度高,或者根本不需要這麼精準,所以我們退而求其次,去找到近似解,只要保證和的誤差在允許的範圍之內即可。那麼如何度量他們的誤差呢?和空間向量距離類似,只不過這裡是矩陣,我們計算出兩個矩陣中每個項之間的誤差,那麼使誤差之和最小的和的將是我們需要的。數學表達如下:
表示使用者i給物品j的評分,也就是評分矩陣的第i行和第j列元素。現在的問題就是求解和使得
最小,為了避免過度擬合,引入正則化因子,優化問題變為
在上式中和都是未知的,是的範數,可以簡單理解成k維向量的模,也即。交替最小二乘法的思想就是先固定其中一個,比如固定,將問題轉換成普通的最小二乘法優化問題,求出另外一個,然後固定,再求解,依次交替進行直到滿足精度要求或者達到指定的迭代次數,交替最小二乘法也因此而得名,所謂顯示反饋是指使用者對感興趣物品有明確的評分,也就是矩陣是明確的。
下面我們先來固定,此時上式是關於的,先將對j求和部分分成兩部分,一部分只有j,另一部分是除了j的其他項,如下:
兩邊對向量求偏導,式子的第二、第三和第四部分對於向量是常數,所以實質上只需要對下列式子求偏導即可
標量C對向量的偏導等於標量C對向量的每個分量偏導,即
所以,我們關注第q個分量求偏導,上式繼續展開
有
再轉回向量,有
類似於一元函式求極值,我們令
有
然後兩邊轉置,有
上面用到了矩陣乘積滿足結合律以及矩陣乘積和矩陣轉置的關係。有
是k階單位矩陣,因為
,
其中是的第j列,且
所以
即
以上以通常多元函式求偏導方法進行的,當然如果你學過矩陣對向量求偏導的知識,可以直接得到這個結果,沒必要這樣繁瑣。
由對稱性,得到
如果優化問題變為
我們有
這裡或就變成了很多文章中寫的。
3、演算法步驟
(1)初始化引數
首先初始化固定的隱藏因子個數k(根據經驗一般選取50~200),引數,迭代總次數r和相鄰兩次誤差C,並隨機產生(s=0,表示首次迭代)
(2)計算
將,和代入公式得到
(3)計算
將,和第(2)步計算出的代入公式,得到
(4)迭代
轉向執行第(2)步,直到達到迭代條件(s>=r)或者相鄰兩次誤差小於某個值結束。
二、隱士反饋交替最小二乘法(ALS-WR)
1、數學模型
上面提到顯示反饋交替最小二乘法(ALS)適用於解決有明確評分矩陣的應用場景,實際情況,使用者沒有明確反饋對物品的偏好。我們只能通過使用者的某些行為來推斷他對物品的偏好,例如使用者瀏覽,收藏,或交易過某個物品,我們可以認為該使用者對這個物品可能感興趣。例如,在使用者瀏覽某個物品中,對該物品的點選次數或者在物品所在頁面上的停留時間越長,這時我們可以推使用者對該物品偏好程度更高,但是對於沒有瀏覽該物品,可能是由於使用者不知道有該物品,我們不能確定的推測使用者不喜歡該物品。ALS-WR通過置信度權重c來解決這些問題:對於更確信使用者偏好的項賦以較大的權重,對於沒有反饋的項,賦以較小的權重。ALS-WR模型的形式化說明如下:
這裡並不是明確的評分,可能是點選某個網頁的次數或者瀏覽某個物品的停留時間等等,是置信度係數。
2、公式推導
推導與ALS基本相同,固定,對求偏導,有
令
有
兩邊轉置,得到
(鑑於本人水平有限,暫證明到此,先粘出很多文章給出的結果,以後有時間證明)
其中和都是對角矩陣。
三、Spark MLlib演算法實現
1、資料準備
資料格式如下:
1,101,5.0
1,102,3.0
1,103,2.5
2,101,2.0
2,102,2.5
2,103,5.0
2,104,2.0
3,101,2.5
3,104,4.0
3,105,4.5
3,107,5.0
4,101,5.0
4,103,3.0
4,104,4.5
4,106,4.0
5,101,4.0
5,102,3.0
5,103,2.0
5,104,4.0
5,105,3.5
5,106,4.0
第一列為使用者id(userId),第二列為物品id(itemId),第三列為使用者給物品的評分,轉換成使用者-物品評分矩陣後,如下:
使用者id/物品id | 101 | 102 | 103 | 104 | 105 | 106 | 107 |
1 | 5.0 | 3.0 | 2.5 | ? | ? | ? | ? |
2 | 2.0 | 2.5 | 5.0 | 2.0 | ? | ? | ? |
3 | 2.5 | ? | ? | 4.0 | 4.5 | ? | 5.0 |
4 | 5.0 | ? | 3.0 | 4.5 | ? | 4.0 | ? |
5 | 4.0 | 3.0 | 2.0 | 4.0 | 3.5 | 4.0 | ? |
2、顯式反饋
Spark MLlib提供了兩種API,一種基於RDD的,在spark.mllib下,該API已經進入維護狀態,預計在Spark 3.0中放棄維護,最新的是基於DataFrame,該API在spark.ml下。關於RDD和DataFrame,如果想了解更多,可以參見《Spark DataSet和RDD與DataFrame轉換成DataSet》、《Spark DataFrame及RDD與DataSet轉換成DataFrame》和《Spark RDD和DataSet與DataFrame轉換成RDD》。
(1)基於RDD
推薦程式碼如下:
package com.leboop.mllib
import org.apache.spark.mllib.recommendation.{ALS, Rating}
import org.apache.spark.sql.SparkSession
/**
* 基於RDD的ALS API推薦Demo
*/
object ALSCFDemo {
// case class Rating(userId: Int, itermId: Int, rating: Float)
/**
* 解析資料:將資料轉換成Rating物件
* @param str
* @return
*/
def parseRating(str: String): Rating = {
val fields = str.split(",")
assert(fields.size == 3)
Rating(fields(0).toInt, fields(1).toInt, fields(2).toFloat)
}
def main(args: Array[String]): Unit = {
//定義切入點
val spark = SparkSession.builder().master("local").appName("ASL-Demo").getOrCreate()
//讀取資料,生成RDD並轉換成Rating物件
val ratingsRDD = spark.sparkContext.textFile("data/ratingdata.csv").map(parseRating)
//隱藏因子數
val rank=50
//最大迭代次數
val maxIter=10
//正則化因子
val labmda=0.01
//訓練模型
val model=ALS.train(ratingsRDD,rank,maxIter,labmda)
//推薦物品數
val proNum=2
//推薦
val r=model.recommendProductsForUsers(proNum)
//列印推薦結果
r.foreach(x=>{
println("使用者 "+x._1)
x._2.foreach(x=>{
println(" 推薦物品 "+x.product+", 預測評分 "+x.rating)
println()
}
)
println("===============================")
}
)
}
}
程式執行結果:
使用者 4
推薦物品 101, 預測評分 4.987222374679642
推薦物品 104, 預測評分 4.498410352539908
===============================
使用者 1
推薦物品 101, 預測評分 4.9941397937874825
推薦物品 104, 預測評分 4.482759123081623
===============================
使用者 3
推薦物品 107, 預測評分 4.9917963612098415
推薦物品 105, 預測評分 4.50190214892064
===============================
使用者 5
推薦物品 101, 預測評分 4.023403087402049
推薦物品 104, 預測評分 3.9938240731866506
===============================
使用者 2
推薦物品 103, 預測評分 4.985059400785903
推薦物品 102, 預測評分 2.4974442131394214
===============================
和的初始值都是隨機產生的,所以每次執行的結果會有差異。從結果中,我們還看到ALS可能會將使用者已經評分的物品推薦給該使用者,這點與Apache Mahout中基於物品或使用者協同過濾不同,例如在使用者-物品評分矩陣中使用者1已經給物品101評5分,推薦結果中也將結果推薦給了他,預測評分4.98非常接近真實值。
這裡我們可以設定不同的初始引數,那麼如何評估哪種結果好些?我們採用如下的均方根誤差RESM:
T為真實值,為預測值。
程式如下:
package com.leboop.mllib
import com.leboop.mllib.ALSCFDemo.rems
import org.apache.spark.mllib.recommendation.{ALS, MatrixFactorizationModel, Rating}
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.SparkSession
/**
* 基於RDD的ALS API推薦Demo
*/
object ALSCFDemo {
/**
* 解析資料:將資料轉換成Rating物件
*
* @param str
* @return
*/
def parseRating(str: String): Rating = {
val fields = str.split(",")
assert(fields.size == 3)
Rating(fields(0).toInt, fields(1).toInt, fields(2).toFloat)
}
/**
* @param model 訓練好的模型
* @param data 真實資料
* @param n 資料個數
* @return 誤差
*/
def rems(model: MatrixFactorizationModel, data: RDD[Rating], n: Long): Double = {
//預測值 Rating(userId,itermId,rating)
val preRDD: RDD[Rating] = model.predict(data.map(d => (d.user, d.product)))
//關聯:組成(預測評分,真實評分)
val doubleRating = preRDD.map(
x => ((x.user, x.product), x.rating)
).join(
data.map { x => ((x.user, x.product), x.rating) }
).values
//計算RMES
math.sqrt(doubleRating.map(x => math.pow(x._1 - x._2, 2)).reduce(_ + _) / n)
}
def main(args: Array[String]): Unit = {
//定義切入點
val spark = SparkSession.builder().master("local").appName("ASL-Demo").getOrCreate()
//讀取資料,生成RDD並轉換成Rating物件
val ratingsRDD = spark.sparkContext.textFile("data/ratingdata.csv").map(parseRating)
//將資料隨機分成訓練資料和測試資料(權重分別為0.8和0.2)
val Array(training, test) = ratingsRDD.randomSplit(Array(1, 0))
//隱藏因子數
val rank = 50
//最大迭代次數
val maxIter = 10
//正則化因子
val labmda = 0.01
//訓練模型
val model = ALS.train(training, rank, maxIter, labmda)
//計算誤差
val remsValue = rems(model, ratingsRDD, ratingsRDD.count)
println("誤差: " + remsValue)
}
}
結果如下:
誤差: 0.011343969370562474
(2)基於DataFrame
程式碼如下:
package com.leboop.mllib
import org.apache.spark.ml.evaluation.RegressionEvaluator
import org.apache.spark.ml.recommendation.ALS
import org.apache.spark.sql.SparkSession
/**
* ASL基於DataFrame的Demo
*/
object ALSDFDemo {
case class Rating(userId: Int, itemId: Int, rating: Float)
/**
* 解析資料:將資料轉換成Rating物件
* @param str
* @return
*/
def parseRating(str: String): Rating = {
val fields = str.split(",")
assert(fields.size == 3)
Rating(fields(0).toInt, fields(1).toInt, fields(2).toFloat)
}
def main(args: Array[String]): Unit = {
//定義切入點
val spark = SparkSession.builder().master("local").appName("ASL-DF-Demo").getOrCreate()
//讀取資料,生成RDD並轉換成Rating物件
import spark.implicits._
val ratingsDF = spark.sparkContext.textFile("data/ratingdata.csv").map(parseRating).toDF()
//將資料隨機分成訓練資料和測試資料(權重分別為0.8和0.2)
val Array(training, test) = ratingsDF.randomSplit(Array(0.8, 0.2))
//定義ALS,引數初始化
val als = new ALS().setRank(50)
.setMaxIter(10)
.setRegParam(0.01)
.setUserCol("userId")
.setItemCol("itemId")
.setRatingCol("rating")
//訓練模型
val model = als.fit(training)
//推薦:每個使用者推薦2個物品
val r = model.recommendForAllUsers(2)
//關閉冷啟動(防止計算誤差不產生NaN)
model.setColdStartStrategy("drop")
//預測測試資料
val predictions = model.transform(test)
//定義rmse誤差計算器
val evaluator = new RegressionEvaluator()
.setMetricName("rmse")
.setLabelCol("rating")
.setPredictionCol("prediction")
//計算誤差
val rmse = evaluator.evaluate(predictions)
//列印訓練資料
training.foreach(x=>println("訓練資料: "+x))
//列印測試資料
test.foreach(x=>println("測試資料: "+x))
//列印推薦結果
r.foreach(x=>print("使用者 "+x(0)+" ,推薦物品 "+x(1)))
//列印預測結果
predictions.foreach(x=>print("預測結果: "+x))
//輸出誤差
println(s"Root-mean-square error = $rmse")
}
}
執行結果如下(中間部分日誌已經刪除):
執行結果如下(中間部分日誌已經刪除):
訓練資料: [1,101,5.0]
訓練資料: [1,102,3.0]
訓練資料: [1,103,2.5]
訓練資料: [2,101,2.0]
訓練資料: [2,102,2.5]
訓練資料: [2,104,2.0]
訓練資料: [3,101,2.5]
訓練資料: [3,105,4.5]
訓練資料: [3,107,5.0]
訓練資料: [4,101,5.0]
訓練資料: [4,103,3.0]
訓練資料: [4,104,4.5]
訓練資料: [4,106,4.0]
訓練資料: [5,102,3.0]
訓練資料: [5,103,2.0]
訓練資料: [5,104,4.0]
訓練資料: [5,105,3.5]
測試資料: [2,103,5.0]
測試資料: [3,104,4.0]
測試資料: [5,101,4.0]
測試資料: [5,106,4.0]
使用者 1 ,推薦物品 WrappedArray([101,4.98618], [105,3.477826])
使用者 3 ,推薦物品 WrappedArray([107,4.9931526], [105,4.499714])
使用者 5 ,推薦物品 WrappedArray([104,3.9853115], [105,3.4996033])
使用者 4 ,推薦物品 WrappedArray([101,5.000056], [104,4.5001974])
使用者 2 ,推薦物品 WrappedArray([105,3.0707152], [102,2.4903712])
預測結果: [5,101,4.0,3.1271331]
預測結果: [2,103,5.0,1.0486442]
預測結果: [5,106,4.0,1.8420099]
預測結果: [3,104,4.0,1.4847627]
Root-mean-square error = 2.615265256309832
3、隱式反饋
與顯式反饋基本相同,這裡需要使用方法setImplicitPrefs()開啟隱式反饋,程式碼如下:
val als = new ALS()
.setMaxIter(5)
.setRegParam(0.01)
.setImplicitPrefs(true)
.setUserCol("userId")
.setItemCol("movieId")
.setRatingCol("rating)
4、幾點問題
(1)冷啟動策略
前面當我們使用已經訓練好的模型model對測試資料進行預測時,可能會碰到測試資料集中的使用者或者物品在訓練資料集中從未出現過。這會出現在兩種情景中:
a、在生產環境中,沒有歷史評分或者模型尚未訓練的新的使用者或者物品(這就是冷啟動問題)
b、在交叉驗證中,資料會被分片成訓練資料和評估資料。當在Spark的CrossValidator
和TrainValidationSplit中使用簡單的隨機分片時,碰到評估資料集中的使用者或者物品沒有出現在訓練資料集中是非常普遍的。
在Spark中,當使用者或者物品在模型中沒有出現過,預設會在模型中指定它們的預測值為NaN。這在生產環境中是有用的,因為這表明它是一個新的使用者或者物品,然後系統可以使用這個預測做出一個撤退的決策。
然而,在交叉驗證中這是不希望的,因為任何NaN的預測值都將導致評估測量中產生NaN(例如,我們程式中使用的RegressionEvaluator),會讓模型無法作出選擇。當然,Spark允許使用者通過設定方法coldStartStrategy
的引數為“drop”來刪除預測結果中含有NaN的任何DataFrame行,這樣,評估度量可以在非NaN的資料上進行計算變得有效。
(2)ALS中引數說明
numBlocks 平行計算的使用者或者物品被分塊的個數。預設是10
rank 模型中隱藏因子的個數,預設是10
maxIter 程式執行的最大迭代次數,預設是10
regParam ALS中的正則化係數,預設是1.0
implicitPrefs 用來確定使用顯示反饋ALS或者調節到隱式反饋(預設是false,使用顯式反饋)
alpha 用於隱式反饋ALS變數的引數,置信係數,預設是1.0
nonnegative 是否使用非負最小二乘法,預設false
(3)Spark中的隱式反饋
spark.ml中的隱式反饋方法來自於Collaborative Filtering for Implicit Feedback Datasets
參考文獻:
深入理解Spark ML:基於ALS矩陣分解的協同過濾演算法與原始碼分析: https://blog.csdn.net/u011239443/article/details/51752904
基於Spark構建推薦引擎之一:基於物品的協同過濾推薦: http://lib.csdn.net/article/spark/33203
GItHub資源搜尋:https://github.com/search?l=Python&q=spark+recommend&type=Repositories
【投稿】Machine Learning With Spark Note :構建簡單的推薦系統:https://blog.csdn.net/u013886628/article/details/51828452
相關文章
- [機器學習]協同過濾演算法的原理和基於Spark 例項機器學習演算法Spark
- 協同過濾演算法概述與python 實現協同過濾演算法基於內容(usr-it演算法Python
- 協同過濾筆記筆記
- Spark Connector Reader 原理與實踐Spark
- 從Spark MLlib到美圖機器學習框架實踐Spark機器學習框架
- 協同過濾實現小型推薦系統
- 推薦系統入門之使用協同過濾實現商品推薦
- 推薦系統與協同過濾、奇異值分解
- 【Datawhale】推薦系統-協同過濾
- 協同過濾的R語言實現及改進R語言
- Spark 以及 spark streaming 核心原理及實踐Spark
- 機器學習庫Spark MLlib簡介與教程機器學習Spark
- WebSocket原理與實踐(二)---WebSocket協議Web協議
- 神經圖協同過濾(Neural Graph Collaborative Filtering)Filter
- 詳解布隆過濾器原理與實現過濾器
- AI開源專案 - Spark MLlibAISpark
- Redis核心原理與實踐--列表實現原理之ziplistRedis
- 推薦召回--基於物品的協同過濾:ItemCF
- Spark MLlib學習(1)--基本統計Spark
- 【轉】推薦系統演算法總結(二)——協同過濾(CF) MF FM FFM演算法
- 基於矩陣分解的協同過濾演算法矩陣演算法
- 協同過濾在推薦系統中的應用
- Redis核心原理與實踐--列表實現原理之quicklist結構RedisUI
- 預測電影偏好?如何利用自編碼器實現協同過濾方法
- 協同編輯功能實現原理概述
- Nestjs最佳實踐教程:4排序,分頁與過濾JS排序
- 阿里雲CDN基於雲邊協同的轉型創新實踐阿里
- angr原理與實踐(一)——原理
- SpringSecurity過濾器原理SpringGse過濾器
- Webpack原理與實踐Web
- MySQL高可用架構之MHA 原理與實踐MySql架構
- Kalman濾波器的原理與實現
- 【小白學推薦1】 協同過濾 零基礎到入門
- spark機器學習:使用ALS完成商品推薦Spark機器學習
- Spark on Yarn 實踐SparkYarn
- MelGan原理與實踐篇
- Vue CLI 原理與實踐Vue
- 時間同步協議NTP - 原理&實踐協議