文件分類(Document Classification)是深度學習在產業與科學上最重要的應用之一:給一篇文件(醫學期刊、路透社新聞、電子郵件、推文、電影評論),預測它的某個屬性。課本以 IMDb 電影評論為例——目標是判斷一則評論的情感是正面還是負面。核心挑戰只有一個:如何把一段長短不一、充滿俚語與錯字的文字,轉換成模型看得懂的數字? 📚 James, Witten, Hastie, Tibshirani (2023) An Introduction to Statistical Learning with Python, §10.4, pp. 413–416
課本引用了 IMDb 資料集中一則「令人捧腹的負評」:
「This has to be one of the worst films of the 1990s. When my friends & I were watching this film... we just sat & watched the first half an hour with our jaws touching the floor at how bad it really was... everyone else in the theater just started talking to each other, leaving or generally crying into their popcorn...」
📚 Maas et al. (2011) "Learning word vectors for sentiment analysis", ACL-HLT 2011, pp. 142–150
這段話沒有固定的長度、有縮寫(&)、有口語——機器要怎麼「讀」它?答案是把文件「特徵化」(featurize),也就是把一篇文字轉成一群預測變數。這正是本節的主題。
最簡單也最常用的特徵化方法是詞袋模型(bag-of-words)。想像你有一個「英語字典」,裡面有 M 個詞。對每一篇文件,我們就建立一個長度 M 的二元特徵向量:這個詞有出現 → 記 1;沒出現 → 記 0。
「詞袋」這個名字很傳神——我們把所有詞丟進一個袋子裡,只記錄「哪些詞在裡面」,完全不管詞與詞的順序和上下文。就像把一本書的所有字剪下來撒在地上,只統計每個字出現了幾次,卻忘了它們原本的排列。
問題來了:英語字典有幾十萬個詞,特徵向量會寬到爆炸。課本的解法是限制字典大小——只用訓練語料中最常出現的 10,000 個詞。於是一篇影評就變成一個長度 10,000 的二元向量。課本展示了一則被「遮罩」過的正面評論:
⟨START⟩this film was just brilliant casting location scenery story direction everyone's really suited the part they played... robert ⟨UNK⟩is an amazing actor... father came from the same scottish island as myself so i loved...
📚 課本 §10.4 的遮罩範例:常見詞被保留,罕見詞被標記為 ⟨UNK⟩
你可以看到很多詞被省略了,未知詞被標成 ⟨UNK⟩。經過這番縮減,每篇影評變成一個 10,000 維的二元向量,絕大部分是 0,只有零星的 1。
IMDb 的訓練集有 25,000 篇評論,詞典有 10,000 個詞,所以特徵矩陣 X 的維度是 25,000 × 10,000——總共 2.5 億個格子。但課本指出:只有 1.3% 的二元項是非零的。這意味著超過 2.4 億個格子都是零。
這種大部分值相同(都是零)的矩陣叫做稀疏矩陣(sparse matrix)。聰明的做法是不存整張表,而是只存「非零項的位置和值」——在本例中,非零項全是 1,所以連值都不用存,只存位置即可。這就把儲存空間壓縮了數十倍,是處理文字資料的標準技巧。
課本還補充:詞袋模型也可以用相對詞頻(而非單純 0/1)來處理文件長度差異,但在本例中,課本選擇最簡單的「詞在或不在」二元表示。
課本從 25,000 篇訓練評論中切出 2,000 篇當驗證集(用於調參),然後訓練兩條模型序列:
課本 Figure 10.11 顯示:兩個模型的訓練準確率都單調遞增(黑點)——這是過擬合的典型徵兆,因為模型越來越能「背」訓練資料。關鍵在於用驗證誤差(藍點)從每個序列中挑出一個好解,再用它對測試集預測。
結論很漂亮:兩個模型都達到約 88% 的測試準確率。這呼應了全書反覆出現的主題——在許多實際問題上,簡單的線性模型(搭配良好的特徵化)和複雜的深度模型表現不相上下。
課本接著揭露了一個重要洞察:一個兩類的神經網路,本質上就是一個非線性的邏輯回歸模型。神經網路的輸出層用 softmax 產生兩個類別的機率,而兩者的對數勝算比可以展開成:
課本式 (10.15):右邊正是以隱藏層輸出 A(2) 為特徵的邏輯回歸,只是這些特徵是神經網路自己學出來的(非線性轉換)。
這個展開同時說明了 softmax 的冗餘性:對 K 個類別,我們其實只需要估計 K−1 組係數(見 §4.3.5)。兩個類別時,只需要一組係數,對應一個勝算比。
課本在此還做了個用詞提醒:Figure 10.11 畫的是準確率(accuracy,答對比例)而非錯誤率(error,答錯比例),前者在機器學習社群更流行。
詞袋模型最大的缺點是:它完全忽略了詞的上下文。課本舉了「blissfully long」和「blissfully short」——若只看單詞,「blissfully」是正面詞,「long」和「short」都是中性詞,詞袋模型無法區分「幸福地長」和「幸福地短」的差異(前者是讚美,後者可能是諷刺)。課本給了至少兩種納入上下文的方法:
課本預告:IMDb 的例子會在 §10.5 用 RNN 繼續深入。
# 10.4 文件分類 — bag-of-words 特徵化示範(自給自足版本)
try:
from google.colab import drive
drive.mount('/content/drive')
DATA_PATH = '/content/drive/MyDrive/ISLP_data/'
except ImportError:
DATA_PATH = '/tmp/'
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import CountVectorizer
# 一組簡短的電影評論(正面 vs 負面)
reviews = [
"brilliant casting scenery story direction",
"this film was just brilliant casting location scenery",
"amazing actor and wonderful story",
"worst film of the 1990s jaw dropping bad",
"just terrible acting awful plot boring",
"crying into my popcorn leaving the theater bad",
"great movie loved the scenery",
"awful boring waste of time terrible",
]
# bag-of-words:二元表示(出現=1,未出現=0)
vec = CountVectorizer(binary=True)
X = vec.fit_transform(reviews).toarray()
words = vec.get_feature_names_out()
print("詞典(特徵):", list(words))
print("特徵矩陣 shape:", X.shape, "(文件數 × 詞典大小)")
print("非零比例: %.1f%%" % (100 * X.sum() / X.size))
plt.figure(figsize=(10, 4))
plt.imshow(X, cmap='gray_r', aspect='auto', interpolation='nearest')
plt.xlabel('詞典特徵(words)')
plt.ylabel('文件(reviews)')
plt.title('Bag-of-Words 二元特徵矩陣(黑=1 出現,白=0 未出現)')
plt.tight_layout()
plt.show()
# 稀疏矩陣:IMDb 的 25,000 × 10,000 矩陣只有 1.3% 非零(自給自足版本)
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from scipy import sparse
# 模擬 IMDb 訓練矩陣的規模
n_docs = 25000
n_words = 10000
non_zero_ratio = 0.013 # 課本:只有 1.3% 的二元項非零
# 隨機生成稀疏二元矩陣(每篇文件平均出現約 130 個詞)
n_nonzero = int(n_docs * n_words * non_zero_ratio)
rows = np.random.randint(0, n_docs, n_nonzero)
cols = np.random.randint(0, n_words, n_nonzero)
data = np.ones(n_nonzero)
X_sparse = sparse.csr_matrix((data, (rows, cols)), shape=(n_docs, n_words))
# 比較稠密 vs 稀疏儲存的記憶體用量
dense_bytes = n_docs * n_words * 8 # float64 每個 8 bytes
sparse_bytes = X_sparse.data.nbytes + X_sparse.indices.nbytes + X_sparse.indptr.nbytes
print("特徵矩陣維度: %d × %d" % (n_docs, n_words))
print("非零項比例: %.1f%%" % (100 * n_nonzero / (n_docs * n_words)))
print("稠密儲存: %.1f MB" % (dense_bytes / 1e6))
print("稀疏儲存: %.2f MB" % (sparse_bytes / 1e6))
print("節省空間: %.0f 倍" % (dense_bytes / sparse_bytes))
print("\nCSR 格式:只存「位置 + 值」。因非零項都是 1,連值都不用存。")
# Lasso 邏輯回歸 vs 兩層神經網路:情感分類比較(自給自足版本)
try:
from google.colab import drive
drive.mount('/content/drive')
DATA_PATH = '/content/drive/MyDrive/ISLP_data/'
except ImportError:
DATA_PATH = '/tmp/'
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.neural_network import MLPClassifier
from sklearn.model_selection import train_test_split
# 生成小型合成「評論」:正面詞 vs 負面詞
pos_words = ["brilliant", "amazing", "wonderful", "great", "loved",
"excellent", "fantastic", "superb", "charming", "delightful"]
neg_words = ["terrible", "awful", "boring", "worst", "bad",
"dreadful", "horrible", "waste", "lousy", "dull"]
rng = np.random.default_rng(42)
def make_doc(label, n_words=25):
pool = pos_words if label == 1 else neg_words
words = list(rng.choice(pool, size=n_words))
fill = list(rng.choice(["film", "movie", "actor", "plot", "scene"], size=5))
return " ".join(words + fill)
n = 300
texts, labels = [], []
for i in range(n):
lab = i % 2 # 平衡:一半正面一半負面
texts.append(make_doc(lab))
labels.append(lab)
X = CountVectorizer(binary=True).fit_transform(texts).astype(float)
y = np.array(labels)
# 切分訓練/驗證/測試(課本:從訓練集切出驗證集調參)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(
X_train, y_train, test_size=0.2, random_state=42)
# 模型一:Lasso(L1 懲罰)邏輯回歸
lasso = LogisticRegression(penalty='l1', solver='liblinear', C=0.1, max_iter=2000)
lasso.fit(X_train, y_train)
# 模型二:兩層神經網路(各 16 個 ReLU 單元,對應課本設定)
nn = MLPClassifier(hidden_layer_sizes=(16, 16), activation='relu',
max_iter=300, random_state=42)
nn.fit(X_train, y_train)
print("=== 驗證集準確率(用於調參)===")
print("Lasso 邏輯回歸: %.3f" % lasso.score(X_val, y_val))
print("兩層神經網路 : %.3f" % nn.score(X_val, y_val))
print("\n=== 測試集準確率 ===")
print("Lasso 邏輯回歸: %.3f" % lasso.score(X_test, y_test))
print("兩層神經網路 : %.3f" % nn.score(X_test, y_test))
print("\n課本結論:兩者測試準確率都約 88%,並無顯著差異。")
print("兩類神經網路本質上就是「非線性邏輯回歸」。")
Gmail 的垃圾郵件過濾器就是最經典的文件分類應用。每封郵件先做詞袋特徵化(出現哪些詞、多少垃圾信號詞),再交給分類器判斷。因為郵件特徵極度稀疏(一封郵件只用到字典裡的一小部分詞),稀疏矩陣是必須的工程技巧,否則數十億封郵件的特徵矩陣會塞爆記憶體。這正是 §10.4 的稀疏表示在業界的日常應用。
PubMed 上有數千萬篇生物醫學論文。研究人員用文件分類自動標註論文的疾病類別、研究類型(臨床試驗/觀察研究/系統回顧)。詞袋模型搭配 Lasso 邏輯回歸的好處是可解釋性——L1 懲罰會自動把大量無關詞的係數壓成 0,留下的就是「真正有鑑別力的關鍵詞」,醫生可以直接看到模型是根據哪些術語做判斷的。
企業監測社群媒體上對自家品牌的評價——正面還是負面?這正是 IMDb 情感分析的直接延伸。推文的特徵化(詞袋或 n-gram)→ 訓練分類器 → 即時打標籤。課本特別提醒的「blissfully long vs blissfully short」陷阱告訴我們:純詞袋會誤判諷刺與否定,需要 n-gram 或序列模型補足上下文。
本節教了兩件可應用於 Agent 設計的事。第一,稀疏表示 = 只記重要的:25,000×10,000 的矩陣只有 1.3% 非零,稀疏格式省下 513 倍空間——這正是 Hermes 記憶管理的哲學,memory 工具不記每個細節,只保留高信號的 durable facts。第二,詞袋忽略語境 = 切碎任務就失去上下文:bag-of-words 只看「出現哪些詞」、不管順序,就像 delegate_task 若沒傳 context,子 agent 就退化成分散的詞、失去前後文。這也呼應了「兩類神經網路=非線性邏輯回歸」的教訓:能用簡單模型(Lasso)解決的,不必上深度模型(NN)——Ponytail 紀律的統計學版本。
| 方法 | 輸入表示 | 上下文? | 模型複雜度 | 可解釋性 | IMDb 效果 |
|---|---|---|---|---|---|
| 詞袋 + Lasso (§10.4) | 二元詞向量(稀疏) | 否 | 線性 | 高(係數即關鍵詞) | ~88% |
| 詞袋 + 神經網路 (§10.4) | 二元詞向量(稀疏) | 否 | 兩隱藏層 | 低 | ~88% |
| CNN (§10.3) | 像素(空間結構) | 局部空間 | 卷積+池化 | 低 | —(影像) |
| RNN (§10.5) | 詞序列(時間結構) | 是(完整序列) | 遞歸層 | 低 | 見 §10.5 |
📚 對照對象選自 ISLP §10.3, §10.4, §10.5。詞袋兩方法準確率相近,但 RNN 透過序列結構可望超越。
「文件分類最深刻的一課不是哪個模型贏了,而是特徵化(featurization)本身。詞袋模型把一篇血肉模糊、長短不一的影評,硬生生壓成一個 10,000 維的 0/1 向量——99% 是零,只有零星幾個 1 標記著『這篇有 brilliant、有 worst』。然後,一個線性的 Lasso 和一個兩層神經網路,竟然打成平手(都是 88%)。這提醒我們:在很多問題上,把力氣花在把資料變成好特徵,比堆疊複雜模型更划算——而『兩類神經網路就是非線性邏輯回歸』這句話,則把深度學習的神秘面紗輕輕掀開了一角。」