跳转至

📖 第3章:文本表示方法

文本表示方法

学习时间:8小时 难度星级:⭐⭐⭐ 前置知识:线性代数基础、Python编程、文本预处理 学习目标:掌握从One-Hot到上下文嵌入的全部文本表示方法,理解Word2Vec数学原理


📋 目录


1. 文本表示概述

1.1 为什么需要文本表示

计算机只能处理数字,无法直接理解文本。文本表示就是将文本转化为数值向量的过程,是NLP中最基础也最关键的一步。

Text Only
文本表示的演进路线:

离散表示 → 静态分布式表示 → 上下文动态表示

One-Hot/BoW/TF-IDF → Word2Vec/GloVe/FastText → ELMo/BERT/GPT
  (2000s以前)              (2013-2017)              (2018至今)

1.2 好的文本表示应该具备什么特性

Python
good_representation = {
    "语义相关性": "语义相近的文本在向量空间中距离近",
    "维度合理": "既不过高(维度灾难)也不过低(信息丢失)",
    "可计算性": "支持加减乘除等向量运算",
    "泛化能力": "能处理未见过的词语或表达",
    "上下文感知": "同一个词在不同语境下有不同表示",
}

2. 离散表示方法

2.1 One-Hot编码

最简单的文本表示:每个词用一个维度,该维度为1其余为0。

Python
import numpy as np

class OneHotEncoder:
    """One-Hot编码器"""

    def __init__(self):
        self.word2idx = {}
        self.idx2word = {}
        self.vocab_size = 0

    def fit(self, corpus):
        """构建词汇表"""
        vocab = set()
        for doc in corpus:
            words = doc.split() if isinstance(doc, str) else doc  # isinstance检查类型
            vocab.update(words)

        self.word2idx = {word: idx for idx, word in enumerate(sorted(vocab))}  # enumerate同时获取索引和元素
        self.idx2word = {idx: word for word, idx in self.word2idx.items()}
        self.vocab_size = len(vocab)

    def encode_word(self, word):
        """单个词编码"""
        vec = np.zeros(self.vocab_size)
        if word in self.word2idx:
            vec[self.word2idx[word]] = 1
        return vec

    def encode_document(self, words):
        """文档编码(方法:求和/平均)"""
        if isinstance(words, str):
            words = words.split()
        return sum(self.encode_word(w) for w in words)

# 示例
corpus = ["我 喜欢 自然语言处理", "自然语言处理 很 有趣", "深度学习 推动 自然语言处理"]
encoder = OneHotEncoder()
encoder.fit(corpus)

print(f"词汇表大小: {encoder.vocab_size}")
print(f"词汇表: {encoder.word2idx}")
print()

# 编码单个词
for word in ["自然语言处理", "喜欢", "深度学习"]:
    vec = encoder.encode_word(word)
    print(f"'{word}': {vec}")

One-Hot的问题: - 维度灾难:词汇表有V个词,向量就是V维 - 语义缺失:任意两个词的向量都正交(余弦相似度为0) - 稀疏性:绝大多数位置为0

\[\text{cos}(\text{OneHot}(\text{猫}), \text{OneHot}(\text{狗})) = 0\]

2.2 词袋模型(Bag of Words)

忽略词序,只关注词的出现频率。

Python
from collections import Counter

class BagOfWords:
    """词袋模型"""

    def __init__(self):
        self.vocabulary = {}
        self.vocab_size = 0

    def fit(self, corpus):
        vocab = set()
        for doc in corpus:
            words = doc if isinstance(doc, list) else doc.split()
            vocab.update(words)
        self.vocabulary = {word: idx for idx, word in enumerate(sorted(vocab))}
        self.vocab_size = len(self.vocabulary)

    def transform(self, document):
        """将文档转为词频向量"""
        words = document if isinstance(document, list) else document.split()
        vec = np.zeros(self.vocab_size)
        word_counts = Counter(words)  # Counter统计元素出现次数
        for word, count in word_counts.items():
            if word in self.vocabulary:
                vec[self.vocabulary[word]] = count
        return vec

    def fit_transform(self, corpus):
        self.fit(corpus)
        return np.array([self.transform(doc) for doc in corpus])  # np.array创建NumPy数组

# 示例
corpus = [
    "我 喜欢 机器学习 和 自然语言处理",
    "自然语言处理 是 机器学习 的 一个 分支",
    "深度学习 推动 了 自然语言处理 的 发展",
]

bow = BagOfWords()
vectors = bow.fit_transform(corpus)

print(f"词汇表: {bow.vocabulary}")
print(f"\nBoW矩阵 (shape={vectors.shape}):")
for i, (doc, vec) in enumerate(zip(corpus, vectors)):  # zip按位置配对
    print(f"  文档{i}: {vec.astype(int)}")

2.3 BoW的局限性

Python
# 词袋模型丢失了词序信息
doc1 = "猫 追 狗"
doc2 = "狗 追 猫"

# 在BoW中,这两个文档的表示完全相同!
bow_demo = BagOfWords()
bow_demo.fit([doc1, doc2])
v1 = bow_demo.transform(doc1)
v2 = bow_demo.transform(doc2)
print(f"'{doc1}' → {v1}")
print(f"'{doc2}' → {v2}")
print(f"两个向量是否相同: {np.array_equal(v1, v2)}")
# True! 但这两句话意思完全不同

3. TF-IDF详解

3.1 原理

TF-IDF(Term Frequency - Inverse Document Frequency)通过加权词频来衡量词的重要性。

核心思想:一个词在当前文档中频繁出现(TF高),但在整个语料库中较少出现(IDF高),则该词对当前文档很重要。

\[\text{TF-IDF}(t, d) = \text{TF}(t, d) \times \text{IDF}(t)\]
\[\text{TF}(t, d) = \frac{f_{t,d}}{\sum_{t' \in d} f_{t',d}}\]
\[\text{IDF}(t) = \log \frac{N}{1 + n_t}\]

其中: - \(f_{t,d}\) = 词t在文档d中出现的次数 - \(N\) = 文档总数 - \(n_t\) = 包含词t的文档数

3.2 手动实现TF-IDF

Python
import math
from collections import Counter
import numpy as np

class TFIDF:
    """TF-IDF实现"""

    def __init__(self):
        self.vocabulary = {}
        self.idf = {}
        self.vocab_size = 0

    def fit(self, corpus):
        """
        corpus: List[List[str]] - 分词后的文档列表
        """
        # 建立词汇表
        vocab = set()
        for doc in corpus:
            vocab.update(doc)
        self.vocabulary = {word: idx for idx, word in enumerate(sorted(vocab))}
        self.vocab_size = len(self.vocabulary)

        # 计算IDF
        N = len(corpus)
        doc_freq = Counter()
        for doc in corpus:
            unique_words = set(doc)
            for word in unique_words:
                doc_freq[word] += 1

        self.idf = {}
        for word in self.vocabulary:
            self.idf[word] = math.log(N / (1 + doc_freq.get(word, 0)))

    def transform(self, document):
        """将文档转为TF-IDF向量"""
        vec = np.zeros(self.vocab_size)
        word_counts = Counter(document)
        total_words = len(document)

        for word, count in word_counts.items():
            if word in self.vocabulary:
                tf = count / total_words
                idf = self.idf.get(word, 0)
                vec[self.vocabulary[word]] = tf * idf

        return vec

    def fit_transform(self, corpus):
        self.fit(corpus)
        return np.array([self.transform(doc) for doc in corpus])

    def get_top_keywords(self, document, top_k=10):
        """获取文档的Top-K关键词"""
        vec = self.transform(document)
        idx2word = {idx: word for word, idx in self.vocabulary.items()}

        top_indices = vec.argsort()[::-1][:top_k]
        keywords = [(idx2word[idx], vec[idx]) for idx in top_indices if vec[idx] > 0]
        return keywords

# 示例
corpus = [
    ["自然语言处理", "是", "人工智能", "的", "重要", "分支"],
    ["机器学习", "和", "深度学习", "推动", "了", "人工智能", "的", "发展"],
    ["自然语言处理", "利用", "深度学习", "技术", "处理", "文本", "数据"],
    ["计算机视觉", "是", "人工智能", "的", "另一个", "重要", "方向"],
    ["自然语言处理", "包括", "文本分类", "命名实体识别", "机器翻译", "等", "任务"],
]

tfidf = TFIDF()
vectors = tfidf.fit_transform(corpus)

print("TF-IDF关键词提取结果:")
for i, doc in enumerate(corpus):
    keywords = tfidf.get_top_keywords(doc, top_k=5)
    doc_str = "".join(doc)[:20]  # 切片操作,取前n个元素
    kw_str = ", ".join([f"{w}({s:.3f})" for w, s in keywords])
    print(f"  文档{i} '{doc_str}...': {kw_str}")

# 计算文档相似度
print("\n文档相似度矩阵:")
for i in range(len(corpus)):
    for j in range(len(corpus)):
        sim = np.dot(vectors[i], vectors[j]) / (np.linalg.norm(vectors[i]) * np.linalg.norm(vectors[j]) + 1e-8)  # np.dot矩阵/向量点乘  # np.linalg线性代数运算
        print(f"{sim:.3f}", end=" ")
    print()

3.3 使用Scikit-learn的TF-IDF

Python
from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np

# 中文需要先分词,用空格连接
corpus = [
    "自然语言处理 是 人工智能 的 重要 分支",
    "机器学习 和 深度学习 推动 了 人工智能 的 发展",
    "自然语言处理 利用 深度学习 技术 处理 文本 数据",
    "计算机视觉 是 人工智能 的 另一个 重要 方向",
]

# 使用sklearn的TF-IDF
vectorizer = TfidfVectorizer()
tfidf_matrix = vectorizer.fit_transform(corpus)

print(f"特征名称: {vectorizer.get_feature_names_out()}")
print(f"TF-IDF矩阵形状: {tfidf_matrix.shape}")
print(f"\nTF-IDF稠密矩阵:")
print(np.round(tfidf_matrix.toarray(), 3))

# 文档相似度
from sklearn.metrics.pairwise import cosine_similarity
similarity = cosine_similarity(tfidf_matrix)
print(f"\n余弦相似度矩阵:")
print(np.round(similarity, 3))

4. N-gram模型

4.1 原理

N-gram通过考虑连续N个词的组合来部分保留词序信息。

\[P(w_n | w_1, w_2, ..., w_{n-1}) \approx P(w_n | w_{n-N+1}, ..., w_{n-1})\]
Python
from collections import Counter, defaultdict

class NGramModel:
    """N-gram语言模型"""

    def __init__(self, n=2):
        self.n = n
        self.ngram_counts = Counter()
        self.context_counts = Counter()
        self.vocabulary = set()

    def train(self, corpus):
        """训练N-gram模型"""
        for sentence in corpus:
            # 添加开始和结束标记
            tokens = ['<s>'] * (self.n - 1) + sentence + ['</s>']
            self.vocabulary.update(sentence)

            for i in range(len(tokens) - self.n + 1):
                ngram = tuple(tokens[i:i + self.n])
                context = tuple(tokens[i:i + self.n - 1])
                self.ngram_counts[ngram] += 1
                self.context_counts[context] += 1

    def probability(self, word, context):
        """计算条件概率 P(word|context)"""
        context = tuple(context)
        ngram = context + (word,)

        context_count = self.context_counts.get(context, 0)
        ngram_count = self.ngram_counts.get(ngram, 0)

        if context_count == 0:
            return 1 / (len(self.vocabulary) + 1)  # 均匀分布

        # 加一平滑(Laplace smoothing)
        return (ngram_count + 1) / (context_count + len(self.vocabulary) + 1)

    def generate(self, start_context=None, max_length=20):
        """基于N-gram模型生成文本"""
        import random

        if start_context is None:
            context = ['<s>'] * (self.n - 1)
        else:
            context = list(start_context)

        result = list(start_context) if start_context else []

        for _ in range(max_length):
            ctx = tuple(context[-(self.n-1):])

            # 获取所有可能的下一个词及其概率
            candidates = {}
            for ngram, count in self.ngram_counts.items():
                if ngram[:-1] == ctx:
                    candidates[ngram[-1]] = count  # [-1]负索引取最后元素

            if not candidates or '</s>' in candidates and len(candidates) == 1:
                break

            # 按频率采样
            words = list(candidates.keys())
            weights = [candidates[w] for w in words]
            total = sum(weights)
            weights = [w/total for w in weights]

            next_word = random.choices(words, weights=weights)[0]

            if next_word == '</s>':
                break

            result.append(next_word)
            context.append(next_word)

        return result

    def perplexity(self, sentence):
        """计算句子的困惑度"""
        tokens = ['<s>'] * (self.n - 1) + sentence + ['</s>']
        log_prob = 0
        N = len(sentence) + 1  # +1 for </s>

        for i in range(self.n - 1, len(tokens)):
            context = tokens[i - self.n + 1:i]
            word = tokens[i]
            prob = self.probability(word, context)
            log_prob += math.log2(prob + 1e-10)

        return 2 ** (-log_prob / N)

# 训练N-gram模型
corpus = [
    ["自然", "语言", "处理", "是", "人工智能", "的", "重要", "分支"],
    ["深度", "学习", "推动", "了", "自然", "语言", "处理", "的", "发展"],
    ["机器", "学习", "是", "人工智能", "的", "基础"],
    ["自然", "语言", "处理", "技术", "应用", "广泛"],
    ["人工智能", "包括", "自然", "语言", "处理", "和", "计算机视觉"],
]

import math

# Bigram模型
bigram = NGramModel(n=2)
bigram.train(corpus)

print("Bigram统计(部分):")
for ngram, count in sorted(bigram.ngram_counts.items(), key=lambda x: -x[1])[:10]:  # lambda匿名函数
    print(f"  {ngram}: {count}")

# 概率查询
print(f"\nP(处理|语言) = {bigram.probability('处理', ['语言']):.4f}")
print(f"P(智能|人工) = {bigram.probability('智能', ['人工']):.4f}")

# 困惑度
test_sent = ["自然", "语言", "处理", "技术"]
ppl = bigram.perplexity(test_sent)
print(f"\n句子困惑度: {''.join(test_sent)} → PPL = {ppl:.2f}")

# 文本生成
print("\n生成文本:")
for _ in range(3):
    generated = bigram.generate(start_context=["自然"])
    print(f"  {''.join(generated)}")

5. Word2Vec详解

5.1 核心思想

"You shall know a word by the company it keeps." — J.R. Firth, 1957

Word2Vec通过词的上下文来学习词的分布式表示。核心假设:语义相近的词倾向于出现在相似的上下文中

5.2 两种架构

Text Only
CBOW (Continuous Bag of Words)       Skip-gram

   context → target                  target → context

   w(t-2) ─┐                            ┌→ w(t-2)
   w(t-1) ─┼→ [hidden] → w(t)    w(t) → [hidden] ─┼→ w(t-1)
   w(t+1) ─┤                            ├→ w(t+1)
   w(t+2) ─┘                            └→ w(t+2)

   已知上下文预测中心词          已知中心词预测上下文
   适合高频词                    适合低频词
   训练速度快                    效果通常更好

5.3 Skip-gram数学推导

目标函数:最大化给定中心词预测上下文词的概率

\[J(\theta) = \frac{1}{T} \sum_{t=1}^{T} \sum_{-c \leq j \leq c, j \neq 0} \log P(w_{t+j} | w_t)\]

Softmax计算

\[P(w_O | w_I) = \frac{\exp(v'_{w_O}{}^T v_{w_I})}{\sum_{w=1}^{V} \exp(v'_w{}^T v_{w_I})}\]

其中: - \(v_{w_I}\) = 中心词的输入向量 - \(v'_{w_O}\) = 上下文词的输出向量 - \(V\) = 词汇表大小

问题:分母需要遍历整个词汇表,计算量太大

5.4 优化方法:负采样

Negative Sampling 将多分类问题转化为多个二分类问题:

\[J_{NEG} = \log \sigma(v'_{w_O}{}^T v_{w_I}) + \sum_{k=1}^{K} \mathbb{E}_{w_k \sim P_n(w)} [\log \sigma(-v'_{w_k}{}^T v_{w_I})]\]

其中 \(\sigma\) 是sigmoid函数,\(K\) 是负样本数量。

负采样损失函数详解:

\[L = -\left[ \log \sigma(u_{o}^T v_c) + \sum_{i=1}^{K} \log \sigma(-u_{i}^T v_c) \right]\]

其中: - \(v_c\):中心词(输入词)的词向量 - \(u_o\):真实上下文词(正样本)的输出向量 - \(u_i\):采样的负样本词的输出向量 - \(K\):负样本数量(通常取5-20) - \(\sigma(x) = \frac{1}{1+e^{-x}}\):sigmoid函数

损失函数的两部分含义: 1. 第一项 \(\log \sigma(u_{o}^T v_c)\):最大化正样本(真实上下文词)的概率 2. 第二项 \(\sum_{i=1}^{K} \log \sigma(-u_{i}^T v_c)\):最小化负样本(随机噪声词)的概率

负采样分布\(P_n(w) \propto f(w)^{0.75}\),其中\(f(w)\)是词频

5.5 Word2Vec简化实现

Python
import numpy as np
from collections import Counter
import random

class SimpleWord2Vec:
    """Word2Vec Skip-gram with Negative Sampling 简化实现"""

    def __init__(self, embedding_dim=50, window_size=2, num_negative=5, learning_rate=0.025):
        self.embedding_dim = embedding_dim
        self.window_size = window_size
        self.num_negative = num_negative
        self.lr = learning_rate
        self.word2idx = {}
        self.idx2word = {}
        self.vocab_size = 0
        self.W = None  # 输入矩阵(词向量)
        self.W_prime = None  # 输出矩阵

    def build_vocab(self, corpus, min_count=1):
        """构建词汇表"""
        word_counts = Counter()
        for sentence in corpus:
            word_counts.update(sentence)

        # 过滤低频词
        vocab = [w for w, c in word_counts.items() if c >= min_count]
        self.word2idx = {w: i for i, w in enumerate(vocab)}
        self.idx2word = {i: w for w, i in self.word2idx.items()}
        self.vocab_size = len(vocab)

        # 计算采样概率(用于负采样)
        total = sum(word_counts[w] for w in vocab)
        self.sample_probs = np.array([
            (word_counts[self.idx2word[i]] / total) ** 0.75
            for i in range(self.vocab_size)
        ])
        self.sample_probs /= self.sample_probs.sum()

        # 初始化权重矩阵
        self.W = np.random.uniform(-0.5/self.embedding_dim, 0.5/self.embedding_dim,
                                    (self.vocab_size, self.embedding_dim))
        self.W_prime = np.zeros((self.vocab_size, self.embedding_dim))

    def sigmoid(self, x):
        """稳定的sigmoid计算"""
        return np.where(x >= 0,
                       1 / (1 + np.exp(-x)),
                       np.exp(x) / (1 + np.exp(x)))

    def generate_training_data(self, corpus):
        """生成训练数据(中心词, 上下文词)对"""
        pairs = []
        for sentence in corpus:
            indices = [self.word2idx[w] for w in sentence if w in self.word2idx]
            for i, center in enumerate(indices):
                # 动态窗口大小
                actual_window = random.randint(1, self.window_size)
                context_range = range(max(0, i - actual_window),
                                     min(len(indices), i + actual_window + 1))
                for j in context_range:
                    if i != j:
                        pairs.append((center, indices[j]))
        return pairs

    def train_pair(self, center_idx, context_idx):
        """训练一个(中心词, 上下文词)对"""
        # 正样本
        center_vec = self.W[center_idx]
        context_vec = self.W_prime[context_idx]

        score = np.dot(center_vec, context_vec)
        pred = self.sigmoid(score)

        # 正样本梯度
        grad = (pred - 1) * self.lr
        center_grad = grad * context_vec
        self.W_prime[context_idx] -= grad * center_vec

        # 负采样
        neg_indices = np.random.choice(self.vocab_size, self.num_negative,
                                        p=self.sample_probs)
        for neg_idx in neg_indices:
            if neg_idx == context_idx:
                continue
            neg_vec = self.W_prime[neg_idx]
            score = np.dot(center_vec, neg_vec)
            pred = self.sigmoid(score)

            grad = pred * self.lr
            center_grad += grad * neg_vec
            self.W_prime[neg_idx] -= grad * center_vec

        # 更新中心词向量
        self.W[center_idx] -= center_grad

    def train(self, corpus, epochs=5):
        """训练模型"""
        self.build_vocab(corpus)
        print(f"词汇表大小: {self.vocab_size}")

        for epoch in range(epochs):
            pairs = self.generate_training_data(corpus)
            random.shuffle(pairs)

            total_loss = 0
            for center, context in pairs:
                self.train_pair(center, context)

            if (epoch + 1) % 1 == 0:
                print(f"Epoch {epoch+1}/{epochs} completed. Pairs: {len(pairs)}")

    def get_vector(self, word):
        """获取词向量"""
        if word in self.word2idx:
            return self.W[self.word2idx[word]]
        return None

    def most_similar(self, word, top_k=5):
        """查找最相似的词"""
        if word not in self.word2idx:
            return []

        vec = self.get_vector(word)
        # 计算余弦相似度
        norms = np.linalg.norm(self.W, axis=1)
        similarities = np.dot(self.W, vec) / (norms * np.linalg.norm(vec) + 1e-8)

        # 排序
        top_indices = similarities.argsort()[::-1][1:top_k+1]  # 排除自身
        return [(self.idx2word[i], similarities[i]) for i in top_indices]

    def analogy(self, a, b, c, top_k=3):
        """词类比: a之于b,相当于c之于? (b - a + c)"""
        if any(w not in self.word2idx for w in [a, b, c]):  # any()任一为True则返回True
            return []

        vec = self.get_vector(b) - self.get_vector(a) + self.get_vector(c)
        norms = np.linalg.norm(self.W, axis=1)
        similarities = np.dot(self.W, vec) / (norms * np.linalg.norm(vec) + 1e-8)

        # 排除输入词
        exclude = {self.word2idx[w] for w in [a, b, c]}
        results = []
        for idx in similarities.argsort()[::-1]:
            if idx not in exclude:
                results.append((self.idx2word[idx], similarities[idx]))
                if len(results) >= top_k:
                    break
        return results

# 训练示例
corpus = [
    ["国王", "住在", "皇宫", "里"],
    ["女王", "住在", "皇宫", "里"],
    ["王子", "是", "国王", "的", "儿子"],
    ["公主", "是", "女王", "的", "女儿"],
    ["男人", "和", "女人", "是", "不同", "的"],
    ["国王", "是", "男人", "的", "统治者"],
    ["女王", "是", "女人", "的", "统治者"],
    ["猫", "是", "可爱", "的", "动物"],
    ["狗", "是", "忠诚", "的", "动物"],
    ["猫", "和", "狗", "都是", "宠物"],
] * 100  # 重复以增加数据量

w2v = SimpleWord2Vec(embedding_dim=30, window_size=2, num_negative=3)
w2v.train(corpus, epochs=10)

# 测试
print("\n最相似的词:")
for word in ["国王", "猫", "动物"]:
    similar = w2v.most_similar(word, top_k=3)
    print(f"  '{word}': {similar}")

5.6 使用Gensim训练Word2Vec

Python
# pip install gensim

from gensim.models import Word2Vec

# 准备训练数据
sentences = [
    ["自然语言", "处理", "是", "人工智能", "的", "重要", "分支"],
    ["深度学习", "推动", "了", "自然语言", "处理", "的", "发展"],
    ["机器学习", "是", "人工智能", "的", "基础"],
    ["词向量", "是", "自然语言", "处理", "的", "基础", "技术"],
    ["BERT", "使用", "Transformer", "架构"],
    ["Transformer", "改变", "了", "自然语言", "处理"],
    ["GPT", "是", "一种", "生成式", "预训练", "模型"],
    ["预训练", "模型", "在", "自然语言", "处理", "中", "表现", "出色"],
] * 50

# 训练Word2Vec
model = Word2Vec(
    sentences=sentences,
    vector_size=100,    # 向量维度
    window=5,           # 窗口大小
    min_count=2,        # 最小词频
    workers=4,          # 线程数
    sg=1,               # 1=Skip-gram, 0=CBOW
    epochs=20,          # 训练轮数
    negative=5,         # 负采样数
)

# 获取词向量
vector = model.wv["自然语言"]
print(f"'自然语言' 向量维度: {vector.shape}")
print(f"'自然语言' 向量前10位: {vector[:10]}")

# 相似词查找
print("\n最相似的词:")
try:  # try/except捕获异常
    similar = model.wv.most_similar("自然语言", topn=5)
    for word, score in similar:
        print(f"  {word}: {score:.4f}")
except KeyError as e:
    print(f"  词不在词汇表中: {e}")

# 保存和加载模型
model.save("word2vec.model")
# loaded_model = Word2Vec.load("word2vec.model")

# 只保存词向量(更紧凑)
model.wv.save("word2vec.wv")
# from gensim.models import KeyedVectors
# wv = KeyedVectors.load("word2vec.wv")

6. GloVe与FastText

6.1 GloVe(Global Vectors)

GloVe通过共现矩阵获取全局统计信息,结合了SVD分解和Word2Vec的优点。

核心公式

\[J = \sum_{i,j=1}^{V} f(X_{ij})(w_i^T \tilde{w}_j + b_i + \tilde{b}_j - \log X_{ij})^2\]

其中 \(X_{ij}\) 是词i和词j的共现次数,\(f\) 是权重函数。

Python
class SimpleGloVe:
    """GloVe简化实现:基于共现矩阵"""

    def __init__(self, embedding_dim=50, window_size=5, x_max=100, alpha=0.75):
        self.embedding_dim = embedding_dim
        self.window_size = window_size
        self.x_max = x_max
        self.alpha = alpha

    def build_cooccurrence_matrix(self, corpus):
        """构建共现矩阵"""
        # 建立词汇表
        vocab = set()
        for sentence in corpus:
            vocab.update(sentence)
        self.word2idx = {w: i for i, w in enumerate(sorted(vocab))}
        self.idx2word = {i: w for w, i in self.word2idx.items()}
        self.vocab_size = len(vocab)

        # 构建共现矩阵
        cooccurrence = np.zeros((self.vocab_size, self.vocab_size))

        for sentence in corpus:
            indices = [self.word2idx[w] for w in sentence if w in self.word2idx]
            for i, idx_i in enumerate(indices):
                for j in range(max(0, i - self.window_size),
                              min(len(indices), i + self.window_size + 1)):
                    if i != j:
                        idx_j = indices[j]
                        # 距离越远,权重越小
                        distance = abs(i - j)
                        cooccurrence[idx_i][idx_j] += 1.0 / distance

        return cooccurrence

    def weight_function(self, x):
        """GloVe权重函数"""
        if x < self.x_max:
            return (x / self.x_max) ** self.alpha
        return 1.0

    def train(self, corpus, epochs=50, lr=0.05):
        """训练GloVe模型"""
        cooc = self.build_cooccurrence_matrix(corpus)
        print(f"词汇表大小: {self.vocab_size}")

        # 初始化
        W = np.random.randn(self.vocab_size, self.embedding_dim) * 0.1
        W_tilde = np.random.randn(self.vocab_size, self.embedding_dim) * 0.1
        b = np.zeros(self.vocab_size)
        b_tilde = np.zeros(self.vocab_size)

        # 获取非零共现对
        nonzero_pairs = []
        for i in range(self.vocab_size):
            for j in range(self.vocab_size):
                if cooc[i][j] > 0:
                    nonzero_pairs.append((i, j, cooc[i][j]))

        print(f"非零共现对数: {len(nonzero_pairs)}")

        # 训练
        for epoch in range(epochs):
            total_loss = 0
            random.shuffle(nonzero_pairs)

            for i, j, x_ij in nonzero_pairs:
                weight = self.weight_function(x_ij)

                # 计算误差
                diff = np.dot(W[i], W_tilde[j]) + b[i] + b_tilde[j] - np.log(x_ij + 1)
                loss = weight * diff ** 2
                total_loss += loss

                # 梯度更新
                grad = weight * diff * lr
                W[i] -= grad * W_tilde[j]
                W_tilde[j] -= grad * W[i]
                b[i] -= grad
                b_tilde[j] -= grad

            if (epoch + 1) % 10 == 0:
                avg_loss = total_loss / len(nonzero_pairs)
                print(f"Epoch {epoch+1}/{epochs}, Avg Loss: {avg_loss:.4f}")

        # 最终词向量 = W + W_tilde(论文建议)
        self.embeddings = W + W_tilde

    def get_vector(self, word):
        if word in self.word2idx:
            return self.embeddings[self.word2idx[word]]
        return None

# 示例(小规模演示)
corpus = [
    ["自然", "语言", "处理", "是", "人工智能"],
    ["深度", "学习", "推动", "自然", "语言", "处理"],
    ["机器", "学习", "是", "人工智能", "基础"],
] * 30

glove = SimpleGloVe(embedding_dim=20)
glove.train(corpus, epochs=30, lr=0.05)

6.2 FastText

FastText的核心创新:使用子词(subword)信息来构建词向量。

Python
class SimpleFastText:
    """FastText核心思想演示:基于子词的词向量"""

    def __init__(self, min_n=3, max_n=6):
        self.min_n = min_n
        self.max_n = max_n

    def get_ngrams(self, word):
        """获取词的所有n-gram"""
        # 添加边界标记
        word = f"<{word}>"
        ngrams = []
        for n in range(self.min_n, self.max_n + 1):
            for i in range(len(word) - n + 1):
                ngrams.append(word[i:i+n])
        return ngrams

ft = SimpleFastText(min_n=3, max_n=6)

# 展示子词分解
words = ["where", "there", "here", "apple"]
for word in words:
    ngrams = ft.get_ngrams(word)
    print(f"'{word}' → {ngrams[:8]}...")  # 只显示前8个
    print()

FastText的优势: 1. 处理未登录词(OOV):即使一个词没在训练集中出现,也可以通过其子词组合得到向量 2. 处理形态变化:sharing/shared/shares 共享子词"shar" 3. 适合形态丰富的语言

6.3 三种词向量对比

特性 Word2Vec GloVe FastText
训练目标 局部上下文预测 全局共现矩阵分解 局部上下文+子词
上下文 局部窗口 全局统计 局部窗口
OOV处理 无法处理 无法处理 子词组合
形态信息 忽略 忽略 利用子词
训练速度 较快 中等
适用场景 通用 通用 形态丰富语言

7. 上下文嵌入(ELMo)

7.1 静态词向量的局限

Python
# Word2Vec/GloVe给每个词一个固定向量 → 无法处理一词多义

examples = {
    "苹果": [
        "我吃了一个苹果",        # 水果
        "苹果公司发布了新产品",   # 公司
    ],
    "bank": [
        "I went to the bank to deposit money",  # 银行
        "The river bank was covered with flowers",  # 河岸
    ],
}

for word, sents in examples.items():
    print(f"'{word}' 的不同含义:")
    for sent in sents:
        print(f"  - {sent}")
    print(f"  → 静态词向量中,两个'{word}'的向量完全相同!")
    print()

7.2 ELMo的核心思想

ELMo(Embeddings from Language Models)使用双向LSTM在大规模语料上预训练,为每个词生成上下文相关的词向量。

Text Only
     ┌─────────────────────────────────────┐
     │            ELMo 架构                 │
     │                                     │
     │    → LSTM₁ → LSTM₁ → LSTM₁ →      │  前向LSTM
     │  [w₁]    [w₂]    [w₃]    [w₄]     │
     │    ← LSTM₂ ← LSTM₂ ← LSTM₂ ←      │  后向LSTM
     │                                     │
     │  最终表示 = γ(s₀·h₀ + s₁·h₁ + s₂·h₂) │
     │                                     │
     │  h₀: 字符级嵌入                      │
     │  h₁: 第1层LSTM隐藏状态               │
     │  h₂: 第2层LSTM隐藏状态               │
     └─────────────────────────────────────┘
\[\text{ELMo}_k = \gamma \sum_{j=0}^{L} s_j \cdot h_{k,j}\]

其中 \(s_j\) 是可学习的层权重,\(\gamma\) 是缩放因子。

7.3 从静态到上下文的演进

Text Only
第一代:One-Hot / BoW / TF-IDF
  - 离散表示,维度高,语义缺失

第二代:Word2Vec / GloVe / FastText
  - 分布式表示,低维稠密,但一词一向量(静态)

第三代:ELMo
  - 上下文相关的词向量,但仍基于LSTM

第四代:BERT / GPT
  - 基于Transformer的双/单向上下文表示
  - 预训练+微调范式

第五代:大模型(GPT-3/4, LLaMA等)
  - 涌现能力,In-Context Learning

8. 代码实战对比

8.1 不同文本表示方法在文本分类中的效果对比

Python
import numpy as np
from collections import Counter
from sklearn.metrics import accuracy_score, f1_score
from sklearn.model_selection import train_test_split

# ==================
# 准备数据
# ==================
# 简单的情感分类数据集
data = [
    ("这部电影太好看了强烈推荐", 1),
    ("非常喜欢剧情很精彩", 1),
    ("拍得真好演技在线", 1),
    ("值得一看好片", 1),
    ("超级好看推荐大家看", 1),
    ("很感动的一部电影", 1),
    ("导演功力深厚", 1),
    ("画面精美剧情紧凑", 1),
    ("太难看了浪费时间", 0),
    ("剧情太差不推荐", 0),
    ("很无聊看了一半就关了", 0),
    ("垃圾电影浪费钱", 0),
    ("太差了别看", 0),
    ("无聊透顶完全看不下去", 0),
    ("失望剧情老套", 0),
    ("烂片一部毫无诚意", 0),
]

texts = [t for t, _ in data]
labels = np.array([l for _, l in data])

# 分词
import jieba
tokenized_texts = [jieba.lcut(t) for t in texts]

# ==================
# 方法1: BoW + 简单分类器
# ==================

class SimpleLogisticRegression:
    """简单逻辑回归用于对比"""
    def __init__(self, n_features, lr=0.1, epochs=100):
        self.W = np.zeros(n_features)
        self.b = 0
        self.lr = lr
        self.epochs = epochs

    def sigmoid(self, x):
        return 1 / (1 + np.exp(-np.clip(x, -500, 500)))

    def fit(self, X, y):
        for _ in range(self.epochs):
            pred = self.sigmoid(X @ self.W + self.b)
            error = pred - y
            self.W -= self.lr * (X.T @ error) / len(y)
            self.b -= self.lr * error.mean()

    def predict(self, X):
        return (self.sigmoid(X @ self.W + self.b) > 0.5).astype(int)

# BoW特征
bow = BagOfWords()
bow_features = bow.fit_transform([' '.join(t) for t in tokenized_texts])

# TF-IDF特征
tfidf_model = TFIDF()
tfidf_features = tfidf_model.fit_transform(tokenized_texts)

# 训练和评估
print("="*50)
print("不同文本表示方法的分类效果对比")
print("="*50)

for method_name, features in [("BoW", bow_features), ("TF-IDF", tfidf_features)]:
    clf = SimpleLogisticRegression(features.shape[1], lr=0.5, epochs=200)
    clf.fit(features, labels)
    predictions = clf.predict(features)
    acc = accuracy_score(labels, predictions)
    print(f"\n{method_name}:")
    print(f"  准确率: {acc:.4f}")
    print(f"  特征维度: {features.shape[1]}")

print("\n" + "="*50)
print("注:以上是在训练集上的结果,实际应用应使用交叉验证")

8.2 词向量可视化

Python
import numpy as np

def visualize_vectors_text(words, vectors, title="词向量2D投影"):
    """
    简化版词向量可视化(文本输出)
    使用PCA降维到2D
    """
    # 简单的PCA实现
    vectors = np.array(vectors)
    mean = vectors.mean(axis=0)
    centered = vectors - mean

    # SVD计算主成分
    U, S, Vt = np.linalg.svd(centered, full_matrices=False)
    projected = centered @ Vt[:2].T

    print(f"\n{title}")
    print("-" * 40)
    print(f"{'词语':<12} {'PC1':<10} {'PC2':<10}")
    print("-" * 40)
    for word, (x, y) in zip(words, projected):
        print(f"{word:<12} {x:>8.4f}  {y:>8.4f}")

    # 计算词间距离
    print(f"\n词间余弦相似度矩阵:")
    for i, w1 in enumerate(words):
        similarities = []
        for j, w2 in enumerate(words):
            v1, v2 = vectors[i], vectors[j]
            sim = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-8)
            similarities.append(f"{sim:.2f}")
        print(f"  {w1:<10}: {' '.join(similarities)}")

# 使用Gensim训练的词向量进行可视化
try:
    words_to_show = ["自然语言", "处理", "人工智能", "深度学习", "机器学习"]
    vectors_to_show = [model.wv[w] for w in words_to_show if w in model.wv]
    valid_words = [w for w in words_to_show if w in model.wv]

    if valid_words:
        visualize_vectors_text(valid_words, vectors_to_show)
except NameError:
    print("请先运行前面的Gensim Word2Vec训练代码")

8.3 预训练词向量加载

Python
# 加载预训练的中文词向量
# 常用的预训练词向量:
# - Tencent AI Lab: https://ai.tencent.com/ailab/nlp/en/embedding.html
# - Chinese Word Vectors: https://github.com/Embedding/Chinese-Word-Vectors

def load_pretrained_vectors(filepath, limit=None):
    """
    加载预训练词向量(Word2Vec文本格式)
    """
    word2vec = {}
    with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:  # with自动管理文件关闭
        # 第一行通常是 vocab_size embedding_dim
        header = f.readline().strip().split()
        vocab_size, embed_dim = int(header[0]), int(header[1])
        print(f"词汇表大小: {vocab_size}, 向量维度: {embed_dim}")

        count = 0
        for line in f:
            parts = line.strip().split()
            word = parts[0]
            vector = np.array([float(x) for x in parts[1:]])
            word2vec[word] = vector
            count += 1
            if limit and count >= limit:
                break

    print(f"加载了 {len(word2vec)} 个词向量")
    return word2vec

# 使用示例(需要有预训练文件)
# vectors = load_pretrained_vectors("sgns.baidubaike.bigram-char.txt", limit=50000)
# print(vectors.get("自然语言处理"))

9. 面试要点

🔑 面试高频考点

考点1:Word2Vec的CBOW和Skip-gram有什么区别?

Text Only
✅ 标准答案要点:

CBOW (Continuous Bag of Words):
- 输入:上下文词 → 输出:中心词
- 适合高频词(有更多上下文样本来训练)
- 训练速度更快
- 适合较小的数据集

Skip-gram:
- 输入:中心词 → 输出:上下文词
- 适合低频词和罕见词
- 效果通常更好
- 对大数据集效果更佳
- 原始论文实验表明Skip-gram在语义任务上表现更好

实际选择:数据量大时用Skip-gram,数据量小时用CBOW

考点2:Word2Vec的负采样为什么有效?

Text Only
✅ 标准答案要点:

1. 问题:原始Softmax需要对整个词汇表求和,计算量O(V)
2. 解决:将多分类转为多个二分类(正样本 vs 负样本)
3. 负采样策略:按词频的3/4次方采样(P(w) ∝ f(w)^0.75)
4. 为什么用3/4次方:
   - 使得高频词的采样概率降低
   - 低频词的采样概率提升
   - 平衡了高频词和低频词的影响

数学表达:
原始: log P(w_o|w_i) = log(exp(v'_o·v_i) / Σexp(v'_k·v_i))
负采样: log σ(v'_o·v_i) + Σ_{k∈NEG} log σ(-v'_k·v_i)

考点3:GloVe和Word2Vec有什么区别?

Text Only
✅ 标准答案要点:

Word2Vec:
- 基于局部上下文窗口(predictive method)
- 通过滑动窗口在线学习
- 优化局部目标函数

GloVe:
- 基于全局共现矩阵(count-based method)
- 先统计全局共现信息,再做矩阵分解
- 优化全局目标函数
- 结合了SVD和Word2Vec的优点

结论:GloVe在某些任务上效果略好,但差异不大
实际中两者表现相当,Word2Vec使用更广泛

考点4:如何评估词向量的质量?

Text Only
✅ 标准答案要点:

内部评估(Intrinsic):
1. 词类比:king - man + woman ≈ queen
2. 词相似度:计算余弦相似度 vs 人工评分的相关性
3. 聚类:语义相近的词是否聚到一起

外部评估(Extrinsic):
1. 作为下游任务的特征:文本分类、NER等
2. 比较不同词向量在下游任务中的表现

常用评测数据集:
- WordSim-353(英文词相似度)
- 中文词向量评测数据集(PKU/NLPCC)
- Google analogy dataset

考点5:TF-IDF的缺点是什么?

Text Only
✅ 标准答案要点:
1. 无法捕捉词序:bag-of-words假设,丢失语序信息
2. 无法捕捉语义:同义词会被视为不同的特征
3. 维度高且稀疏:词汇表大时向量维度很高
4. 无法处理OOV:新词无法表示
5. 假设词的独立性:忽略词与词之间的关系

适用场景:
- 简单文本分类/检索的baseline
- 关键词提取
- 文档相似度计算
- 计算效率要求高的场景

10. 练习题

📝 基础题

  1. 手动计算以下文档集的TF-IDF矩阵:
  2. 文档1: "我 喜欢 猫"
  3. 文档2: "我 喜欢 狗"
  4. 文档3: "猫 和 狗 是 动物"

答案:词汇表={我,喜欢,猫,狗,和,是,动物}。TF为词频/文档词数,IDF=log(N/df)(N=3)。以"猫"为例:TF(猫,文档1)=⅓,df(猫)=2,IDF=log(3/2)≈0.176,TF-IDF≈0.059。"我""喜欢"出现在2篇文档中IDF相同;"和""是""动物"只出现在文档3,IDF=log(3/1)≈0.477,TF-IDF值最高——体现了TF-IDF对区分性词汇赋予更高权重的核心思想。

  1. 解释为什么Word2Vec能捕捉"king - man + woman ≈ queen"这样的语义关系。

答案:Word2Vec通过上下文预测任务训练词向量,使语义相似的词在向量空间中距离相近。"king"和"queen"出现在相似上下文(皇室、统治相关),"man"和"woman"也共享性别相关上下文。这导致向量空间中形成平行的语义关系方向:"king→queen"与"man→woman"一致,都编码了"性别转换"这一语义维度。因此king-man得到"去除男性特征的皇室概念",加上woman后得到queen。本质上,Word2Vec将语义关系编码为向量空间中的线性结构

💻 编程题

  1. 实现一个完整的TF-IDF模型,支持:
  2. 自定义IDF公式(标准/平滑/概率)
  3. 文档关键词提取
  4. 文档相似度计算

  5. 使用Gensim的Word2Vec在中文语料上训练词向量,并进行:

  6. 最相似词查找
  7. 词类比测试
  8. 词向量可视化

  9. 对比BoW、TF-IDF、Word2Vec(平均词向量)在文本分类任务上的效果差异。

🔬 思考题

  1. 在BERT时代,Word2Vec还有使用价值吗?如果有,在什么场景下?

答案:仍有价值。①资源受限场景:Word2Vec模型小、推理快,适合移动端/嵌入式设备;②大规模检索:用Word2Vec做初步语义匹配召回,再用BERT精排;③冷启动/低资源:标注数据极少时,预训练Word2Vec比微调BERT更稳定;④可解释性需求:词向量直观可解释(词类比、相似词),便于特征分析;⑤特征拼接:作为下游模型的静态特征输入,与其他特征组合使用。

  1. 为什么FastText在处理拼写错误和形态丰富语言时比Word2Vec好?

答案:FastText将每个词拆分为字符n-gram的集合,词向量是其所有n-gram向量之和。①拼写错误鲁棒性:"whre"(少了e)仍与"where"共享大部分n-gram,向量相近;而Word2Vec将其视为OOV无法表示。②形态丰富语言:"teach/teacher/teaching"共享词根n-gram,FastText能自动捕捉词形变化关系;德语、芬兰语等有大量复合词和词缀,n-gram机制天然适合。本质上,FastText利用了亚词级信息,而Word2Vec只能处理完整词。


✅ 自我检查清单

Text Only
□ 我能手动计算TF-IDF值
□ 我能用自己的话解释Word2Vec的CBOW和Skip-gram
□ 我理解负采样的原理和为什么用3/4次方
□ 我知道GloVe和Word2Vec的核心区别
□ 我能使用Gensim训练Word2Vec模型
□ 我了解从One-Hot到ELMo的文本表示演进
□ 我知道不同文本表示方法的适用场景
□ 我完成了至少4道练习题

📚 延伸阅读

  1. Word2Vec原始论文: Efficient Estimation of Word Representations in Vector Space
  2. GloVe原始论文: Global Vectors for Word Representation
  3. FastText论文: Enriching Word Vectors with Subword Information
  4. ELMo论文: Deep contextualized word representations
  5. CS224N Lecture 1-2: Word Vectors

下一篇04-文本分类 — NLP最常见的任务:文本分类全解析