情感分析(Sentiment analysis)是自然語言處理(NLP)方法中常見的應用,尤其是以提煉文字情緒內容為目的的分類。利用情感分析這樣的方法,可以通過情感評分對定性資料進行定量分析。雖然情感充滿了主觀性,但情感定量分析已經有許多實用功能,例如企業藉此瞭解使用者對產品的反映,或者判別線上評論中的仇恨言論。
情感分析最簡單的形式就是藉助包含積極和消極詞的字典。每個詞在情感上都有分值,通常 +1 代表積極情緒,-1 代表消極。接著,我們簡單累加句子中所有詞的情感分值來計算最終的總分。顯而易見,這樣的做法存在許多缺陷,最重要的就是忽略了語境(context)和鄰近的詞。例如一個簡單的短語“not good”最終的情感得分是 0,因為“not”是 -1,“good”是 +1。正常人會將這個短語歸類為消極情緒,儘管有“good”的出現。
另一個常見的做法是以文字進行“詞袋(bag of words)”建模。我們把每個文字視為 1 到 N
的向量,N
是所有詞彙(vocabulary)的大小。每一列是一個詞,對應的值是這個詞出現的次數。比如說短語“bag of bag of words”可以編碼為 [2, 2, 1]。這個值可以作為諸如邏輯迴歸(logistic regression)、支援向量機(SVM)的機器學習演算法的輸入,以此來進行分類。這樣可以對未知的(unseen)資料進行情感預測。注意這需要已知情感的資料通過監督式學習的方式(supervised fashion)來訓練。雖然和前一個方法相比有了明顯的進步,但依然忽略了語境,而且資料的大小會隨著詞彙的大小增加。
Word2Vec 和 Doc2Vec
近幾年,Google 開發了名為 Word2Vec 新方法,既能獲取詞的語境,同時又減少了資料大小。Word2Vec 實際上有兩種不一樣的方法:CBOW(Continuous Bag of Words,連續詞袋)和 Skip-gram。對於 CBOW,目標是在給定鄰近詞的情況下預測單獨的單詞。Skip-gram 則相反:我們希望給定一個單獨的詞(見圖 1)來預測某個範圍的詞。兩個方法都使用人工神經網路(Artificial Neural Networks)來作為它們的分類演算法。首先,詞彙表中的每個單詞都是隨機的 N 維向量。在訓練過程中,演算法會利用 CBOW 或者 Skip-gram 來學習每個詞的最優向量。圖 1:CBOW 以及 Skip-Gram 結構圖,選自《Efficient Estimation of Word Representations in Vector Space》。W(t) 代表當前的單詞,而w(t-2), w(t-1) 等則是鄰近的單詞。
這些詞向量現在可以考慮到上下文的語境了。這可以看作是利用基本的代數式來挖掘詞的關係(例如:“king” – “man” + “woman” = “queen”)。這些詞向量可以作為分類演算法的輸入來預測情感,有別於詞袋模型的方法。這樣的優勢在於我們可以聯絡詞的語境,並且我們的特徵空間(feature space)的維度非常低(通常約為 300,相對於約為 100000 的詞彙)。在神經網路提取出這些特徵之後,我們還必須手動建立一小部分特徵。由於文字長度不一,將以全體詞向量的均值作為分類演算法的輸入來歸類整個文件。
然而,即使使用了上述對詞向量取均值的方法,我們仍然忽略了詞序。Quoc Le 和 Tomas Mikolov 提出了 Doc2Vec 的方法對長度不一的文字進行描述。這個方法除了在原有基礎上新增 paragraph / document 向量以外,基本和 Word2Vec 一致,也存在兩種方法:DM(Distributed Memory,分散式記憶體)和分散式詞袋(DBOW)。DM 試圖在給定前面部分的詞和 paragraph 向量來預測後面單獨的單詞。即使文字中的語境在變化,但 paragraph 向量不會變化,並且能儲存詞序資訊。DBOW 則利用paragraph 來預測段落中一組隨機的詞(見圖 2)。
圖 2: Doc2Vec 方法結構圖,選自《Distributed Representations of Sentences and Documents》。
一旦經過訓練,paragraph 向量就可以作為情感分類器的輸入而不需要所有單詞。這是目前對 IMDB 電影評論資料集進行情感分類最先進的方法,錯誤率只有 7.42%。當然,如果這個方法不實用,說這些都沒有意義。幸運的是,一個 Python 第三方庫 gensim 提供了 Word2Vec 和 Doc2Vec 的優化版本。
基於 Python 的 Word2Vec 舉例
在本節我們將會展示怎麼在情感分類任務中使用詞向量。gensim
這個庫是 Anaconda 發行版中的標配,你同樣可以利用 pip 來安裝。利用它你可以在自己的語料庫(一個文件資料集)中訓練詞向量或者匯入 C text 或二進位制格式的已經訓練好的向量。
1 2 3 4 5 |
from gensim.models.word2vec import Word2Vec model = Word2Vec.load_word2vec_format('vectors.txt', binary=False) #C text 格式 model = Word2Vec.load_word2vec_format('vectors.bin', binary=True) #二進位制格式 |
我發現讀取谷歌已經訓練好的詞向量尤其管用,這些向量來自谷歌新聞(Google News),由超過千億級別的詞訓練而成,“已經訓練過的詞和短語向量”可以在這裡找到。注意未壓縮的檔案有 3.5 G。通過 Google 詞向量我們能夠發現詞與詞之間有趣的關聯:
1 2 3 4 5 6 7 8 9 10 11 12 |
from gensim.models.word2vec import Word2Vec model = Word2Vec.load_word2vec_format('GoogleNews-vectors-negative300.bin', binary=True) model.most_similar(positive=['woman', 'king'], negative=['man'], topn=5) [(u'queen', 0.711819589138031), (u'monarch', 0.618967592716217), (u'princess', 0.5902432799339294), (u'crown_prince', 0.5499461889266968), (u'prince', 0.5377323031425476)] |
有趣的是它可以發現語法關係,例如識別最高階(superlatives)和動詞詞幹(stems):
“biggest” – “big” + “small” = “smallest”
1 2 3 4 5 6 7 8 |
model.most_similar(positive=['biggest','small'], negative=['big'], topn=5) [(u'smallest', 0.6086569428443909), (u'largest', 0.6007465720176697), (u'tiny', 0.5387299656867981), (u'large', 0.456944078207016), (u'minuscule', 0.43401968479156494)] |
“ate” – “eat” + “speak” = “spoke”
1 2 3 4 5 6 7 8 |
model.most_similar(positive=['ate','speak'], negative=['eat'], topn=5) [(u'spoke', 0.6965223550796509), (u'speaking', 0.6261293292045593), (u'conversed', 0.5754593014717102), (u'spoken', 0.570488452911377), (u'speaks', 0.5630602240562439)] |
由以上例子可以清楚認識到 Word2Vec 能夠學習詞與詞之間的有意義的關係。這也就是為什麼它對於許多 NLP 任務有如此大的威力,包括在本文中的情感分析。在我們用它解決起情感分析問題以前,讓我們先測試一下 Word2Vec 對詞分類(separate)和聚類(cluster)的本事。我們會用到三個示例詞集:食物類(food)、運動類(sports)和天氣類(weather),選自一個非常棒的網站 Enchanted Learning。因為這些向量有 300 個維度,為了在 2D 平面上視覺化,我們會用到 Scikit-Learn’s 中叫作“t-SNE”的降維演算法操作
首先必須像下面這樣取得詞向量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import numpy as np with open('food_words.txt', 'r') as infile: food_words = infile.readlines() with open('sports_words.txt', 'r') as infile: sports_words = infile.readlines() with open('weather_words.txt', 'r') as infile: weather_words = infile.readlines() def getWordVecs(words): vecs = [] for word in words: word = word.replace('n', '') try: vecs.append(model[word].reshape((1,300))) except KeyError: continue vecs = np.concatenate(vecs) return np.array(vecs, dtype='float') #TSNE expects float type values food_vecs = getWordVecs(food_words) sports_vecs = getWordVecs(sports_words) weather_vecs = getWordVecs(weather_words) |
我們接著使用 TSNE
和 matplotlib
視覺化聚類,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
from sklearn.manifold import TSNE import matplotlib.pyplot as plt ts = TSNE(2) reduced_vecs = ts.fit_transform(np.concatenate((food_vecs, sports_vecs, weather_vecs))) #color points by word group to see if Word2Vec can separate them for i in range(len(reduced_vecs)): if i < len(food_vecs): #food words colored blue color = 'b' elif i >= len(food_vecs) and i < (len(food_vecs) + len(sports_vecs)): #sports words colored red color = 'r' else: #weather words colored green color = 'g' plt.plot(reduced_vecs[i,0], reduced_vecs[i,1], marker='o', color=color, markersize=8) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import numpy as np with open('food_words.txt', 'r') as infile: food_words = infile.readlines() with open('sports_words.txt', 'r') as infile: sports_words = infile.readlines() with open('weather_words.txt', 'r') as infile: weather_words = infile.readlines() def getWordVecs(words): vecs = [] for word in words: word = word.replace('n', '') try: vecs.append(model[word].reshape((1,300))) except KeyError: continue vecs = np.concatenate(vecs) return np.array(vecs, dtype='float') #TSNE 要求浮點型的值 food_vecs = getWordVecs(food_words) sports_vecs = getWordVecs(sports_words) weather_vecs = getWordVecs(weather_words) |
結果如下:
圖 3:食物類單詞(藍色),運動類單詞(紅色)和天氣類單詞(綠色)T-SNE 叢集效果圖。
我們可以從上面的例子看到,Word2Vec 不僅能有效分類不相關的單詞,同樣也能聚類類似的詞。
推特 Emoji 情感分析
現在我們進入下一個例程,利用符號表情作為搜尋詞的推特情感分析。我們把這些符號表情作為我們資料的“模糊(fuzzy)”標籤;微笑表情(:-))與積極情緒對應,而皺眉表情(:-()則對應消極情緒。在大約 400,000 條推特資料中,積極和消極的各佔一半(even split)。我們對積極和消極情緒的推特進行了隨機取樣,並按80 / 20 的比例分為了訓練集/ 測試集。我們接著在 Word2Vec 模型上訓練推特。為了避免資料洩露(data leakage),在訓練資料集分類完成以前我們都不會在 Word2Vec 上訓練。為了結構化分類器的輸入,我們對所有推特詞向量取均值。我們會用到 Scikit-Learn 這個第三方庫做大量的機器學習。
我們首先匯入我們的資料並訓練 Word2Vec 模型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
from sklearn.cross_validation import train_test_split from gensim.models.word2vec import Word2Vec with open('twitter_data/pos_tweets.txt', 'r') as infile: pos_tweets = infile.readlines() with open('twitter_data/neg_tweets.txt', 'r') as infile: neg_tweets = infile.readlines() # 1 代表積極情緒,0 代表消極情緒 y = np.concatenate((np.ones(len(pos_tweets)), np.zeros(len(neg_tweets)))) x_train, x_test, y_train, y_test = train_test_split(np.concatenate((pos_tweets, neg_tweets)), y, test_size=0.2) # 零星的預處理 def cleanText(corpus): corpus = [z.lower().replace('n','').split() for z in corpus] return corpus x_train = cleanText(x_train) x_test = cleanText(x_test) n_dim = 300 # 初始化模型並建立詞彙表(vocab) imdb_w2v = Word2Vec(size=n_dim, min_count=10) imdb_w2v.build_vocab(x_train) # 訓練模型 (會花費幾分鐘) imdb_w2v.train(x_train) |
下面我們必須對輸入文字建立詞向量,為了平均推特中的所有詞向量,將用到如下的函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# 對訓練資料集建立詞向量,接著進行比例縮放(scale)。 def buildWordVector(text, size): vec = np.zeros(size).reshape((1, size)) count = 0. for word in text: try: vec += imdb_w2v[word].reshape((1, size)) count += 1. except KeyError: continue if count != 0: vec /= count return vec |
對我們的資料集進行縮放是標準化處理的一部分。通過均值為零的高斯分佈,意味著大於均值則為積極,小於則為消極。許多機器學習模型要求使用縮放過的資料集來獲得更好的處理效果,尤其是多特徵(例如文字分類)。
1 2 3 4 5 6 7 |
from sklearn.preprocessing import scale train_vecs = np.concatenate([buildWordVector(z, n_dim) for z in x_train]) train_vecs = scale(train_vecs) # 在測試推特資料集中訓練 Word2Vec imdb_w2v.train(x_test) |
最終我們必須建立測試資料向量並進行比例縮放來評估。
1 2 3 4 |
# 建立測試推特向量並縮放 test_vecs = np.concatenate([buildWordVector(z, n_dim) for z in x_test]) test_vecs = scale(test_vecs) |
下面我們想通過計算測試資料的預測精度來驗證我們的分類器,同時測試它們的 ROC 曲線(Receiver Operating Characteristic,受試者操作特徵曲線)。當模型引數調節時,ROC 曲線會測試分類器的真陽性(true-positive)以及假陽性(false-positive)。本例中,我們通過調節邊界閾值概率(cut-off threshold probability)將某條推特分類為積極或消極情緒。通常,更希望得到最大化的真陽性和最小化的假陽性,也就是 ROC 曲線下方最大的區域(AUC)。通過這裡更多地瞭解 ROC 曲線。
開始訓練我們的分類器,本例對邏輯迴歸(Logistic Regression)使用隨機梯度下降(Stochastic Gradient Descent)。
1 2 3 4 5 6 7 |
# 使用分類演算法(例如:隨機邏輯迴歸(Stochastic Logistic Regression)來訓練資料集,接著從 sklearn.linear_model 匯入 SGDClassifier 進行模型處理) lr = SGDClassifier(loss='log', penalty='l1') lr.fit(train_vecs, y_train) print 'Test Accuracy: %.2f'%lr.score(test_vecs, y_test) |
我們利用 matplotlib 和 Scikit-Learn 的 metric
包中的 roc_curve 建立 ROC 曲線來評估。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# 建立 ROC 曲線 from sklearn.metrics import roc_curve, auc import matplotlib.pyplot as plt pred_probas = lr.predict_proba(test_vecs)[:,1] fpr,tpr,_ = roc_curve(y_test, pred_probas) roc_auc = auc(fpr,tpr) plt.plot(fpr,tpr,label='area = %.2f' %roc_auc) plt.plot([0, 1], [0, 1], 'k--') plt.xlim([0.0, 1.0]) plt.ylim([0.0, 1.05]) plt.legend(loc='lower right') plt.show() |
曲線結果如下:
圖 4:邏輯分類器對推特訓練資料的 ROC 曲線
沒有建立任何特徵以及最小化的文字預處理,利用 Scikit-Learn 提供的簡單線性模型我們已經實現了 73% 的測試準確率。有趣的是,移除了標點符號實際上反而降低了準確率,說明當“?”或“!”出現時,Word2Vec 能夠找到有趣的特徵。將這些標點視為獨立的單詞,訓練更長的時間,做更多的預處理,調節 Word2Vec 和分類器中的引數這些方法都有助於準確率的提升。我已經發現配合使用人工神經網路(ANN)能夠提高大概 5% 的準確率。因為 Scikit-Learn 沒有提供 ANN 分類器的實現工具,所以我自己寫了一個:
1 2 3 4 5 6 7 8 9 |
from NNet import NeuralNet nnet = NeuralNet(100, learn_rate=1e-1, penalty=1e-8) maxiter = 1000 batch = 150 _ = nnet.fit(train_vecs, y_train, fine_tune=False, maxiter=maxiter, SGD=True, batch=batch, rho=0.9) print 'Test Accuracy: %.2f'%nnet.score(test_vecs, y_test) |
最終準確率為 77%。不論什麼機器學習任務,選對模型的藝術性大於科學性。如果你想用我寫的庫你可以在這找到。友情提示,它看起來比較亂並且沒有定期維護!如果你想貢獻程式碼歡迎 fork 我的程式碼倉。它非常需要被寵幸(TLC)。
基於 Doc2Vec 的電影評論分析
在推特的例子中,使用詞向量的均值效果良好。這是因為推特通常是幾十個詞的長度,即使取均值也能保留相關的特徵。然而,一旦我們上升到段落的規模,忽略詞序和上下文資訊將面臨丟失大量特徵的風險。這樣的情況下更適合使用 Doc2Vec 建立輸入特徵。我們將使用 IMDB 電影評論資料集 作為示例來測試 Word2Vec 在情感分析中的有效性。資料集中包含了 25,000 條積極評論,25,000 條消極評論和 50,000 條未標記的電影評論。我們首先利用 Doc2Vec 對未標記評論進行訓練。除了同時使用 DM 和 DBOW 向量作為輸入以外,方法和上一節 Word2Vec 例子相同。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
import gensim LabeledSentence = gensim.models.doc2vec.LabeledSentence from sklearn.cross_validation import train_test_split import numpy as np with open('IMDB_data/pos.txt','r') as infile: pos_reviews = infile.readlines() with open('IMDB_data/neg.txt','r') as infile: neg_reviews = infile.readlines() with open('IMDB_data/unsup.txt','r') as infile: unsup_reviews = infile.readlines() # 1 代表積極情緒,0 代表消極情緒 y = np.concatenate((np.ones(len(pos_reviews)), np.zeros(len(neg_reviews)))) x_train, x_test, y_train, y_test = train_test_split(np.concatenate((pos_reviews, neg_reviews)), y, test_size=0.2) # 零星的預處理 def cleanText(corpus): punctuation = """.,?!:;(){}[]""" corpus = [z.lower().replace('n','') for z in corpus] corpus = [z.replace('<br />', ' ') for z in corpus] # 將標點視為一個單詞 for c in punctuation: corpus = [z.replace(c, ' %s '%c) for z in corpus] corpus = [z.split() for z in corpus] return corpus x_train = cleanText(x_train) x_test = cleanText(x_test) unsup_reviews = cleanText(unsup_reviews) # Gensim 的 Doc2Vec 工具要求每個文件/段落包含一個與之關聯的標籤。我們利用 LabeledSentence 進行處理。格式形如 “TRAIN_i” 或者 “TEST_i”,其中 “i” 是假的評論索引。 def labelizeReviews(reviews, label_type): labelized = [] for i,v in enumerate(reviews): label = '%s_%s'%(label_type,i) labelized.append(LabeledSentence(v, [label])) return labelized x_train = labelizeReviews(x_train, 'TRAIN') x_test = labelizeReviews(x_test, 'TEST') unsup_reviews = labelizeReviews(unsup_reviews, 'UNSUP') |
這麼一來建立了 LabeledSentence 型別物件:
1 2 |
<gensim.models.doc2vec.LabeledSentence at 0xedd70b70> |
下面我們例項化兩個 Doc2Vec 模型,DM 和 DBOW。gensim 文件建議多次訓練資料,並且在每一步(pass)調節學習率(learning rate)或者用隨機順序輸入文字。接著我們收集了通過模型訓練後的電影評論向量。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
import random size = 400 # 例項化 DM 和 DBOW 模型 model_dm = gensim.models.Doc2Vec(min_count=1, window=10, size=size, sample=1e-3, negative=5, workers=3) model_dbow = gensim.models.Doc2Vec(min_count=1, window=10, size=size, sample=1e-3, negative=5, dm=0, workers=3) # 對所有評論建立詞彙表 model_dm.build_vocab(np.concatenate((x_train, x_test, unsup_reviews))) model_dbow.build_vocab(np.concatenate((x_train, x_test, unsup_reviews))) # 多次傳入資料集,通過每次滑動(shuffling)來提高準確率。 all_train_reviews = np.concatenate((x_train, unsup_reviews)) for epoch in range(10): perm = np.random.permutation(all_train_reviews.shape[0]) model_dm.train(all_train_reviews[perm]) model_dbow.train(all_train_reviews[perm]) # 從我們的模型中獲得訓練過的向量 def getVecs(model, corpus, size): vecs = [np.array(model[z.labels[0]]).reshape((1, size)) for z in corpus] return np.concatenate(vecs) train_vecs_dm = getVecs(model_dm, x_train, size) train_vecs_dbow = getVecs(model_dbow, x_train, size) train_vecs = np.hstack((train_vecs_dm, train_vecs_dbow)) # 訓練測試資料集 x_test = np.array(x_test) for epoch in range(10): perm = np.random.permutation(x_test.shape[0]) model_dm.train(x_test[perm]) model_dbow.train(x_test[perm]) # 建立測試資料集向量 test_vecs_dm = getVecs(model_dm, x_test, size) test_vecs_dbow = getVecs(model_dbow, x_test, size) test_vecs = np.hstack((test_vecs_dm, test_vecs_dbow)) |
現在我們準備對我們的評論向量訓練一個分類器。我們再次使用 sklearn 的 SGDClassifier
。
1 2 3 4 5 6 7 |
from sklearn.linear_model import SGDClassifier lr = SGDClassifier(loss='log', penalty='l1') lr.fit(train_vecs, y_train) print 'Test Accuracy: %.2f'%lr.score(test_vecs, y_test) |
這個模型的測試準確率達到了 0.86。我們也構建瞭如下的分類器 ROC 曲線:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#Create ROC curve from sklearn.metrics import roc_curve, auc %matplotlib inline import matplotlib.pyplot as plt pred_probas = lr.predict_proba(test_vecs)[:,1] fpr,tpr,_ = roc_curve(y_test, pred_probas) roc_auc = auc(fpr,tpr) plt.plot(fpr,tpr,label='area = %.2f' %roc_auc) plt.plot([0, 1], [0, 1], 'k--') plt.xlim([0.0, 1.0]) plt.ylim([0.0, 1.05]) plt.legend(loc='lower right') plt.show() |
圖 5:基於 IMDB 電影評論訓練資料的邏輯分類器(logistic classifier)的 ROC 曲線
原始論文 強調了用有 50 個結點的神經網路加上一個簡單的邏輯迴歸分類器,效果會有提高:
1 2 3 4 5 6 7 8 9 |
from NNet import NeuralNet nnet = NeuralNet(50, learn_rate=1e-2) maxiter = 500 batch = 150 _ = nnet.fit(train_vecs, y_train, fine_tune=False, maxiter=maxiter, SGD=True, batch=batch, rho=0.9) print 'Test Accuracy: %.2f'%nnet.score(test_vecs, y_test) |
有趣的是,我們在這兒並沒有看到什麼提高。測試準確率是 0.85,我們也沒能達到他們所說的 7.42% 的測試錯誤率。原因有很多:我們在每一步(epochs)對於訓練/測試資料沒有訓練足夠,他們實現 Doc2Vec 和 ANN 的方式不同,他們的超引數不同等等。因為論文中並沒有談及細節,所以難以確知真正原因。不管怎樣,在進行了零星預處理以及沒有構造和選取特徵的情況下,我們還是得到了 86% 的準確率。並不需要花哨的卷積(convolutions)和樹庫(treebanks)!
結論
我希望已經你不僅見識了 Word2Vec 和 Doc2Vec 的強大,而且能夠通過標準工具諸如 Python 和 gensim 來應用它們。只需要非常簡單的演算法我們即可得到豐富的詞和段落向量,足以在所有 NLP 應用中使用。另外更棒的是 Google 釋出了基於超大規模資料集預訓練(pre-train)的詞向量。如果你想在大規模資料集中訓練自己的詞向量,可以利用 Apache Spark’s MLlib 的 Word2Vec 來實現。Happy NLP’ing!
擴充套件閱讀
- A Word is Worth a Thousand Vectors
- Word2Vec Tutorial
- Gensim
- Scikit-Learn: Working with Text Data
- Natural Language Processing with Python
如果你喜歡這篇文章並且不想錯過其它同類文章,在部落格主頁點選 Subscribe 的按鈕吧!
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式