1. Intro to Natural Language Processing
題目整理
建立最基本的 NLP 前處理管線:把原始文字正規化(轉小寫、去標點)、斷詞成 token,從語料建立詞彙表,再把任意句子轉成 bag-of-words 計數向量(每個維度是某個字出現幾次)。這是文字進到任何模型前的第一步。
解法說明
preprocess 用 lower() 統一大小寫,再用正規表示式 [a-z0-9']+ 抓出「連續字母數字」當作 word(順手把標點與空白當分隔)。BagOfWords 從語料收集排序後的 vocab 建 stoi,vectorize 掃過 token 累加對應維度;沒看過的字直接忽略。BoW 的特點是只看字頻、不看順序。
import re
from typing import Dict, List
def preprocess(text: str) -> List[str]:
text = text.lower()
return re.findall(r"[a-z0-9']+", text) # Words = maximal alnum runs.
class BagOfWords:
def __init__(self, corpus: List[str]):
vocab = sorted({tok for doc in corpus for tok in preprocess(doc)})
self.stoi: Dict[str, int] = {w: i for i, w in enumerate(vocab)}
def vectorize(self, text: str) -> List[int]:
vec = [0] * len(self.stoi)
for tok in preprocess(text):
if tok in self.stoi: # Ignore out-of-vocab words.
vec[self.stoi[tok]] += 1
return vec
vectorize O(句子長度)。Interview Explanation Flow
Step 1: Name the pipeline stages
"Classic NLP preprocessing is: normalize, tokenize, build a vocabulary, then vectorize. I'd state these four steps up front because every downstream model assumes them."
Step 2: Walk one document
corpus = ["The cat sat.", "The dog ran!"]
preprocess("The cat sat.") -> ["the", "cat", "sat"]
vocab = ["cat","dog","ran","sat","the"] (sorted)
stoi = {cat:0, dog:1, ran:2, sat:3, the:4}
vectorize("the cat the") -> [1, 0, 0, 0, 2]
# cat the appears twice
Step 3: State the key limitation
"Bag-of-words throws away order — 'dog bites man' and 'man bites dog' get identical vectors. It's also sparse and high-dimensional. That's exactly why dense word embeddings and, later, attention exist."
Possible follow-ups
- TF-IDF? "Weight each count by how rare the word is across documents, downweighting common words like 'the'."
- Handling unseen words? "Ignore them, or reserve an UNK dimension — same idea as the tokenizer edge-cases problem."
中文:重點是 normalize → tokenize → vocab → vectorize 四步,並點出 BoW 丟失詞序、向量稀疏,鋪陳後面的 embeddings 與 attention。
延伸練習:Unicode-aware preprocessing(follow-up)
上面的 preprocess 只吃 ASCII:[a-z0-9'] 遇到 café、Zürich、北京、Москва 會把重音字、非拉丁字母切爛或丟掉。以下是不改動原題解、額外提供的 Unicode 版本;Python 3 的 \w 對 str pattern 預設就是 Unicode。
import re
import unicodedata
def preprocess_unicode(text: str) -> list:
text = unicodedata.normalize("NFC", text) # 1. Compose accents to canonical form.
text = text.casefold() # 2. Unicode-aware lowercasing.
# 3. Word = run of Unicode letters/digits (not underscore), keeping contractions.
return re.findall(r"[^\W_]+(?:['’][^\W_]+)*", text)
三個步驟各修一個 Unicode 陷阱:
normalize("NFC"):é可能是單一 codepointU+00E9,也可能是e+ 結合重音U+0301;兩者外觀相同但是不同字串,會讓 vocab 對不上。NFC 先合成成同一種標準形式。casefold()取代lower():casefold 是給「無視大小寫比對」用的正確版本。例如德文"Straße".casefold()→'strasse'、希臘文字尾 sigma 也處理正確,lower()都會漏。[^\W_]+(?:['’][^\W_]+)*:[^\W_]是「Unicode 詞字元但排除底線」(純\w會把_也算進去);後面允許詞內單引號,且同時吃 ASCII'與真實文本常見的 Unicode 右單引號’(U+2019),讓it's/it’s都保持完整。
行為對比:
s = "Café Zürich — it's naïve. 北京 Москва don’t"
# 原題 ASCII 版
re.findall(r"[a-z0-9']+", s.lower())
-> ['caf', 'z', 'rich', "it's", 'na', 've', 'don'] # 重音與非拉丁字母被切爛
preprocess_unicode(s)
-> ['café', 'zürich', "it's", 'naïve', '北京', 'москва', 'don’t'] # 保持完整
\w+ 會貪婪抓一整串詞字元,但中文/日文/泰文不用空白斷詞,所以 北京大学 會變成一個 token而不是 北京 + 大学。若真的需要 CJK 詞邊界,regex 不夠,得用斷詞器(中文 jieba、日文 fugashi / MeCab);bag-of-words baseline 常見的便宜替代是對 CJK 改用字元級切分。