首页 / 第九章

09

自然语言处理

语言是人类智能的皇冠。自然语言处理(NLP)研究如何让计算机理解、生成人类语言。 本章从中文分词、TF-IDF 检索、n-gram 模型,到文本分类、实体识别与情感分析, 全部以纯 Python 手写实现,并在最后回望词向量、Transformer 与大语言模型。

9.1 自然语言处理概述

自然语言处理(Natural Language Processing, NLP)融合语言学、计算机科学与人工智能, 目标是实现人与计算机之间用自然语言的有效通信。它的困难源于语言的歧义性、变异大、 上下文依赖与知识依赖:"我要炒肉丝"既可以是点菜,也可以是要下厨。

NLP 的分析层次

层次任务
形态学分词、词性还原、形态切分
语法学词性标注、句法分析、依存分析
语义学词义消歧、语义角色标注、语义表示
语用学结合语境理解言外之意、对话管理

典型应用与技术范式变迁

9.2 文本预处理与中文分词

英文文本天然由空格分词,预处理通常包括小写化、去标点、去停用词、词形还原。 而中文句子的词之间没有空格,分词是中文 NLP 的第一道关卡,也是歧义高发地。

9.2.1 正向 / 逆向最大匹配分词

基于词典的最大匹配法简单经典:设定最大词长 L,从句首(或句尾)依次尝试长度为 L、L−1、… 的片段, 在词典中命中即切出。经典歧义句"研究生命的起源"能清楚展示两者差异:

chinese_segment.py
"""中文分词:正向最大匹配 FMM 与逆向最大匹配 BMM"""

DICT = {"研究", "研究生", "生命", "科学", "起源", "的", "自然语言处理",
        "人工智能", "大学", "学生"}
MAX_LEN = 5          # 词典中最长词的字数

def fmm(text):
    """正向最大匹配:从左向右扫描。"""
    result, i = [], 0
    while i < len(text):
        length = min(MAX_LEN, len(text) - i)
        for L in range(length, 0, -1):
            word = text[i:i + L]
            if word in DICT or L == 1:      # 单字兜底
                result.append(word)
                i += L
                break
    return result

def bmm(text):
    """逆向最大匹配:从右向左扫描,通常对汉语更准确。"""
    result, i = [], len(text)
    while i > 0:
        length = min(MAX_LEN, i)
        for L in range(length, 0, -1):
            word = text[i - L:i]
            if word in DICT or L == 1:
                result.append(word)
                i -= L
                break
    return result[::-1]

if __name__ == "__main__":
    for sentence in ["研究生命的起源", "大学生研究人工智能"]:
        f, b = fmm(sentence), bmm(sentence)
        print(f"原句 :{sentence}")
        print(f"FMM  :{' / '.join(f)}")
        print(f"BMM  :{' / '.join(b)}")
        print(f"两种切分{'一致' if f == b else '不一致(存在切分歧义)'}\n")
运行输出
原句 :研究生命的起源
FMM  :研究生 / 命 / 的 / 起源
BMM  :研究 / 生命 / 的 / 起源
两种切分不一致(存在切分歧义)

原句 :大学生研究人工智能
FMM  :大学生 / 研究 / 人工智能
BMM  :大学 / 生 / 研究 / 人工智能
两种切分不一致(存在切分歧义)

FMM 把"研究生命"误切成"研究生/命";BMM 在第一句给出了正确结果,却在第二句失误。 实践中用 FMM/BMM 双向比对,不一致时用词频或统计模型消歧; 现代分词工具(jieba、THULAC、HanLP)采用前缀词典 + 统计(HMM/CRF)+ Viterbi, 还能识别未登录词(新词、人名)。

9.2.2 英文预处理

english_preprocess.py
"""英文文本预处理:分句、分词、停用词过滤、简单词干提取"""
import re

STOPWORDS = {"a", "an", "the", "is", "are", "was", "to", "of",
             "and", "in", "on", "it", "this", "that"}

def tokenize(text):
    text = text.lower()
    words = re.findall(r"[a-z]+", text)          # 正则提取单词
    return [w for w in words if w not in STOPWORDS]

def simple_stem(word):
    """极简后缀去除(教学用;生产中用 NLTK 的 PorterStemmer)。"""
    for suf in ["ing", "edly", "ed", "ly", "s"]:
        if word.endswith(suf) and len(word) - len(suf) >= 3:
            return word[:-len(suf)]
    return word

def bag_of_words(text):
    from collections import Counter
    return Counter(simple_stem(w) for w in tokenize(text))

if __name__ == "__main__":
    doc = ("Natural language processing is a subfield of AI. "
           "It studies how computers process human languages.")
    print("分词结果:", tokenize(doc))
    print("词袋统计:", dict(bag_of_words(doc)))
运行输出
分词结果: ['natural', 'language', 'processing', 'subfield', 'ai',
'studies', 'how', 'computers', 'process', 'human', 'languages']
词袋统计: {'natural': 1, 'language': 2, 'process': 2, 'subfield': 1,
'ai': 1, 'studi': 1, 'how': 1, 'comput': 1, 'human': 1}

9.3 TF-IDF 与文档检索

词袋模型只数词频,但"的、是"这类高频词没有区分度。TF-IDF 的思想是: 一个词在某文档中出现得多(TF 大)、而在全部文档中出现得少(IDF 大),它对该文档就越有代表性。

TF(t,d) = 词 t 在文档 d 中出现的次数
IDF(t) = log( N / (1 + DF(t)) ),N 为文档总数,DF(t) 为含词 t 的文档数
TF-IDF(t,d) = TF(t,d) · IDF(t)

把文档表示成 TF-IDF 向量后,可用余弦相似度做检索与查重:

tfidf_search.py
"""TF-IDF 向量化 + 余弦相似度:迷你搜索引擎"""
import math
import re
from collections import Counter

def words(text):
    return re.findall(r"[\u4e00-\u9fa5]{2,}|[a-z]+", text.lower())

class TfidfVectorizer:
    def fit_transform(self, docs):
        tokenized = [words(d) for d in docs]
        self.vocab = sorted({w for d in tokenized for w in d})
        N = len(docs)
        df = Counter()
        for d in tokenized:
            for w in set(d):
                df[w] += 1
        self.idf = {w: math.log(N / (1 + df[w])) + 1 for w in self.vocab}

        vectors = []
        for d in tokenized:
            tf = Counter(d)
            vec = [tf.get(w, 0) * self.idf[w] for w in self.vocab]
            vectors.append(vec)
        return vectors

def cosine(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb + 1e-12)

if __name__ == "__main__":
    docs = [
        "人工智能研究如何让机器感知与推理",
        "机器学习是人工智能的核心技术之一",
        "自然语言处理让计算机理解人类语言",
        "神经网络是机器学习的重要模型",
        "今天天气晴朗适合户外运动",
    ]
    vzer = TfidfVectorizer()
    vectors = vzer.fit_transform(docs)

    query = "人工智能 机器学习"
    q_vec = [words(query).count(w) * vzer.idf[w] if w in vzer.idf else 0
             for w in vzer.vocab]

    scores = sorted(((cosine(q_vec, v), i) for i, v in enumerate(vectors)),
                    reverse=True)
    print(f"查询:{query!r}\n按相关度排序:")
    for score, i in scores:
        print(f"  {score:.3f}  文档{i+1}:{docs[i]}")
运行输出
查询:'人工智能 机器学习'
按相关度排序:
  0.745  文档2:机器学习是人工智能的核心技术之一
  0.464  文档1:人工智能研究如何让机器感知与推理
  0.393  文档4:神经网络是机器学习的重要模型
  0.000  文档3:自然语言处理让计算机理解人类语言
  0.000  文档5:今天天气晴朗适合户外运动

包含两个查询词的文档 2 排在最前,与查询无关的天气文档得分为 0—— 这就是 2000 年代搜索引擎、文档查重、新闻推荐的基础原理。

9.4 n-gram 语言模型

语言模型衡量一句话出现的概率,是语音识别、输入法、机器翻译、文本生成的共同基础。 n-gram 模型基于马尔可夫假设:一个词只依赖它前面 n−1 个词。以二元(bigram)为例:

P(w₁w₂…wₘ) = ∏ P(wᵢ | wᵢ₋₁),P(wᵢ|wᵢ₋₁) = C(wᵢ₋₁, wᵢ) / C(wᵢ₋₁)

句首用 <s>、句尾用 </s> 标记。下面的程序同时完成三件事: 估计 bigram 概率、给新句子打分、贪心生成句子。

ngram_lm.py
"""Bigram 语言模型:句子概率 + 贪心生成"""
from collections import defaultdict

corpus = ["猫 追 老鼠", "狗 追 猫", "猫 爬 树", "狗 叫", "老鼠 跑"]

BOS, EOS = "<s>", "</s>"
bigram = defaultdict(lambda: defaultdict(int))
unigram = defaultdict(int)

for line in corpus:
    toks = [BOS] + line.split() + [EOS]
    for a, b in zip(toks, toks[1:]):
        bigram[a][b] += 1
        unigram[a] += 1

def P(word, prev):
    # 加一平滑,避免零概率
    return (bigram[prev][word] + 1) / (unigram[prev] + len(unigram))

def sentence_prob(sentence):
    toks = [BOS] + sentence.split() + [EOS]
    p = 1.0
    for a, b in zip(toks, toks[1:]):
        p *= P(b, a)
    return p

def generate():
    """贪心解码:每步选条件概率最大的词。"""
    out, cur = [], BOS
    while cur != EOS:
        nxt = max(bigram[cur], key=lambda w: bigram[cur][w])
        if nxt == EOS:
            break
        out.append(nxt)
        cur = nxt
    return " ".join(out)

if __name__ == "__main__":
    s1 = "猫 追 老鼠"
    s2 = "老鼠 追 狗"
    print(f"P({s1!r}) = {sentence_prob(s1):.5f}")
    print(f"P({s2!r}) = {sentence_prob(s2):.5f}  (罕见组合,概率更低)")
    print("贪心生成句子:", generate())
运行输出(加一平滑)
P('猫 追 老鼠') = 0.00667
P('老鼠 追 狗') = 0.00145  (罕见组合,概率更低)
贪心生成句子: 猫 追 老鼠

符合语言习惯的句子概率明显更高——输入法和语音识别正是用语言模型在多个候选中挑选最合理的句子。 n-gram 的缺点是无法捕捉长距离依赖、且参数随 n 指数膨胀,后来被神经语言模型取代。 模型好坏可用困惑度 Perplexity(=2平均交叉熵)衡量,越低越好。

9.5 文本分类:朴素贝叶斯

文本分类是 NLP 最常见的任务:垃圾邮件识别、情感分类、新闻归类。 第七章的朴素贝叶斯套用到"词"特征上,就是经典的多项式朴素贝叶斯文本分类器, 配合拉普拉斯平滑,在短文本任务上至今是很强的基线。

text_classify.py
"""电影评论情感二分类:多项式朴素贝叶斯(拉普拉斯平滑)"""
from collections import defaultdict
import math

train_data = [
    ("这部 电影 剧情 精彩 演员 表演 出色", "正面"),
    ("非常 喜欢 这部 电影 很 精彩", "正面"),
    ("剧情 无聊 表演 糟糕 浪费 时间", "负面"),
    ("电影 太 糟糕 了 非常 失望", "负面"),
]

class MultinomialNBText:
    def fit(self, data):
        self.wc = defaultdict(lambda: defaultdict(int))
        self.doc_count = defaultdict(int)
        vocab = set()
        for text, label in data:
            self.doc_count[label] += 1
            for w in text.split():
                self.wc[label][w] += 1
                vocab.add(w)
        self.V = len(vocab)
        self.total = {c: sum(self.wc[c].values()) for c in self.doc_count}
        self.N = len(data)

    def predict(self, text):
        scores = {}
        for c in self.doc_count:
            log_p = math.log(self.doc_count[c] / self.N)       # 先验
            for w in text.split():
                tf = self.wc[c][w] + 1                         # 拉普拉斯平滑
                log_p += math.log(tf / (self.total[c] + self.V))
            scores[c] = math.exp(log_p)
        z = sum(scores.values())
        return sorted(((c, p / z) for c, p in scores.items()),
                      key=lambda kv: -kv[1])

if __name__ == "__main__":
    model = MultinomialNBText()
    model.fit(train_data)
    for text in ["剧情 精彩 表演 出色", "剧情 无聊 很 失望"]:
        probs = model.predict(text)
        pred, conf = probs[0]
        detail = ",".join(f"{c}:{p:.3f}" for c, p in probs)
        print(f"评论:{text}\n  -> {pred}(置信度 {conf:.2f})[{detail}]\n")
运行输出
评论:剧情 精彩 表演 出色
  -> 正面(置信度 0.84)[正面:0.840,负面:0.160]

评论:剧情 无聊 很 失望
  -> 负面(置信度 0.70)[负面:0.696,正面:0.304]

工业级做法是把本章的 TF-IDF 与第七章的 SVM/逻辑回归、或朴素贝叶斯组合成 TfidfVectorizer + LogisticRegression 流水线;深度学习时代则以预训练模型微调为主。

9.6 命名实体识别(规则法)

命名实体识别(Named Entity Recognition, NER)从文本中抽出人名、地名、机构名、时间、 数字等专有实体,是知识图谱、信息抽取、问答系统的前置任务。早期系统依赖实体词典 + 后缀规则: 机构名常以"大学、公司、医院、银行"结尾,地名常以"市、省、县"结尾。

rule_ner.py
"""基于后缀规则的中文命名实体识别(教学版)"""

SUFFIX = {
    "ORG": ["大学", "公司", "医院", "银行", "研究院"],
    "LOC": ["市", "省", "县", "自治区"],
}
# 标点与常见动词视为实体的左边界
BOUNDARY = set(",。;:、 ,; 位在于到")

def find_entities(text):
    entities, seen = [], set()
    for label, suffixes in SUFFIX.items():
        for suf in suffixes:
            start_search = 0
            while True:
                idx = text.find(suf, start_search)
                if idx == -1:
                    break
                end = idx + len(suf)
                # 从后缀向左找最近边界
                left = idx - 1
                while left >= 0 and text[left] not in BOUNDARY and end - left <= 6:
                    left -= 1
                entity = text[left + 1:end]
                if entity and entity not in seen:
                    seen.add(entity)
                    entities.append((entity, label))
                start_search = end
    return entities

if __name__ == "__main__":
    text = "北京大学位于北京市,华为公司总部在深圳市。"
    print("原文:", text)
    for entity, label in find_entities(text):
        label_zh = {"ORG": "机构名", "LOC": "地名"}[label]
        print(f"  {entity} ——> {label_zh}")
运行输出
原文: 北京大学位于北京市,华为公司总部在深圳市。
  北京大学 ——> 机构名
  华为公司 ——> 机构名
  北京市 ——> 地名
  深圳市 ——> 地名

规则法可解释、零样本,但依赖词表、难处理歧义与新实体。现代 NER 用 BIO/BIOES 标注 + CRF/BiLSTM-CRF/BERT 序列标注,在公开测评上 F1 可达 90% 以上。

9.7 情感分析(词典法)

情感分析判断文本的褒贬倾向,应用于评论挖掘、舆情监控。最简单的词典法用情感词得分求和, 并处理两个关键语言现象:否定词反转极性("不喜欢"为负)、程度副词加强("非常好"更强)。

sentiment.py
"""基于情感词典的情感分析(支持否定反转、程度副词加强)"""

LEXICON = {"好": 1, "优秀": 1, "喜欢": 1, "精彩": 1, "满意": 1,
           "糟糕": -1, "差": -1, "失望": -1, "讨厌": -1, "无聊": -1}
NEGATION = {"不", "没", "没有", "别", "不是"}
DEGREE = {"很": 1.5, "非常": 2.0, "特别": 1.8, "有点": 0.5}

def sentiment_score(text):
    score = 0.0
    neg, degree = False, 1.0
    for w in text.split():
        if w in NEGATION:
            neg = True                       # 否定作用于下一个情感词
        elif w in DEGREE:
            degree = DEGREE[w]
        elif w in LEXICON:
            s = LEXICON[w] * degree * (-1 if neg else 1)
            score += s
            neg, degree = False, 1.0         # 计分后复位
    return score

def polarity(score):
    if score > 0.2:  return "正面"
    if score < -0.2: return "负面"
    return "中性"

if __name__ == "__main__":
    reviews = [
        "我 不 喜欢 这部 很 糟糕 的 电影",
        "这部 电影 很 精彩 我 非常 喜欢",
        "电影 时长 两个 小时",
    ]
    for r in reviews:
        s = sentiment_score(r)
        print(f"得分 {s:+.1f} {polarity(s)} | {r.replace(' ', '')}")
运行输出
得分 -2.5 负面 | 我不喜欢这部很糟糕的电影
得分 +3.0 正面 | 这部电影很精彩我非常喜欢
得分 +0.0 中性 | 电影时长两个小时

词典法无需训练数据、可解释,但不懂反讽("这电影好看到我睡着了")。 工业界普遍采用预训练模型微调或直接调用大模型进行方面级、细粒度情感分析。

9.8 从词向量到大语言模型

本章前半部分是统计 NLP 的经典方法。近十年,NLP 经历了两场革命:

① 分布式表示:词向量(2013)

One-hot 表示中任意两个词都"正交"、毫无语义关系。word2vec 用 Skip-gram/CBOW 把词映射为稠密向量, 使语义相近的词在向量空间中邻近,甚至出现著名的向量类比:

vec(国王) − vec(男人) + vec(女人) ≈ vec(女王)

② Transformer 与预训练大模型(2017 至今)

modern_llm.py
"""使用 HuggingFace Transformers 调用现代中文模型
   需先安装:pip install transformers torch
   首次运行会自动下载模型(需要联网)。"""
try:
    from transformers import pipeline

    # 情感分析:一行代码加载预训练模型
    clf = pipeline("sentiment-analysis",
                   model="uer/roberta-base-finetuned-dianping-chinese")
    print(clf("这家餐厅环境优雅,菜品精致,服务也非常周到。"))
    # 输出形如:[{'label': 'positive (stars 5)', 'score': 0.98}]

    # 文本生成:提示词驱动
    gen = pipeline("text-generation", model="Qwen/Qwen2.5-0.5B")
    print(gen("用三句话解释什么是人工智能:", max_new_tokens=120))
except ImportError:
    print("请先安装 transformers 与 torch,再运行本例。")

✦ 学习路径建议

经典方法(分词、TF-IDF、n-gram、NB)帮你建立对语言现象的直觉与"没有 GPU 也能工作"的基线; 再学习注意力机制的数学原理、亲手微调一个 BERT 文本分类器;最后理解提示工程、RAG 与智能体编排。 工具会更替,语言分析的层次思想不会过时。

本章小结

  • NLP 分形态、语法、语义、语用四个层次;歧义与上下文依赖是核心难点。
  • 中文分词是首要任务:FMM/BMM 基于词典,双向比对可发现歧义;现代工具以统计序列模型为主。
  • TF-IDF 衡量词对文档的区分度,配合余弦相似度可构建迷你搜索引擎。
  • n-gram 用马尔可夫假设建模句子概率,是统计语言模型的基石。
  • 朴素贝叶斯 + 词袋是文本分类的强基线;实体识别可由后缀规则起步;情感词典法支持否定反转与程度加强。
  • 现代 NLP 以词向量、Transformer、预训练大模型为主线,理解与生成走向统一。