第6章 自然语言处理:让机器处理文本任务

本章技术主题: 自然语言处理——把文字转换为可学习的表示和任务输出。
本章技术实验: 对家庭事务文字做分类和字段抽取,并保留原文证据。
家庭项目: “把零散家庭事务变成可执行计划”是主项目;“建立家庭健康资料与就医准备助手”是模拟脱敏的受控案例。
跨章位置: 本章只生成候选事项和待确认项;日历等外部写入留到第12章讨论,并必须预览确认。
技术主线: 家庭事务文本 → 词元与文本表示 → 分类/信息抽取 → 候选字段 → 原文核对 → 边界测试。

学习目标

学完本章,你将能够:

  1. 区分文本分类、信息抽取和语言生成三类自然语言处理任务;
  2. 用生活语言解释词元、词表、嵌入表示和文本分类概率;
  3. 运行一个使用Keras/TensorFlow完成家庭事务文本分类的小模型,并用规则抽取明确字段;
  4. 比较关键词规则、小文本模型和通用语言模型的能力与限制;
  5. 用歧义、遗漏、否定、冲突和陌生表达测试结果,决定哪些字段可以进入候选清单。

项目导入

家庭事务可能来自社区通知、缴费提醒、预约短信、家校消息和家庭成员留言,表达方式并不统一。有的公布活动,有的提醒截止时间,有的临时调整安排,还有的只是一般说明。学生真正需要的往往不是一段更流畅的摘要,而是知道“这是什么类型的事项”“可能需要做什么”“时间和地点是否明确”“哪些信息仍待确认”。

本章直接使用已经转成文字的家庭事务材料,先训练一个小文本分类模型,把文本分为event(活动)、deadline(截止事项)、schedule_change(安排变更)和general(一般信息);再用透明规则抽取原文明确写出的日期、时间和地点。图片中的文字识别(OCR)和语音转写(ASR)只是可选的上游输入方式,不是本章文本模型的能力;若使用它们,必须同时保留原图片或录音片段供核对。通用语言模型可以作为第三种方案提出候选字段,但同样不能把常识补写成家庭事实。

课堂只使用教师编写的模拟材料、公开通知或经过脱敏整理的文本,不处理真实姓名、电话号码、账号和精细家庭作息。作品生成的是待核对候选,不自动报名、不发送消息,也不直接写入外部日历。“变成可执行计划”是指人回看原文、补齐或确认行动后形成清单,不是模型替家庭作决定。确需进入日历时,必须先显示事项、日期、时间、地点、来源和不确定项的预览,由人逐项确认后再写入。

本章成果

考点提示

文本分类为整段文字选择类别,信息抽取从原文中寻找特定字段,语言生成则根据上下文连续产生文字。三者可能串联,但不是同一任务。结构化字段便于程序检查,却不能保证内容正确;任何日期、地点和行动都应保留原文片段。小模型的类别概率只在当前标签集合中有意义,陌生家庭事务也可能得到较高分。

第一节 用文本分类与信息抽取整理家庭事务

一、把事务整理拆成两个任务

第一项任务是文本分类。输入一段事务文本,输出四个类别的分数和最高候选。例如,“周六下午在社区活动室举行旧物交换活动”更接近event;“本月物业费最迟于5月18日17:00前完成缴纳”更接近deadline。类别由项目设计者定义,不是自然界唯一正确的划分。

第二项任务是信息抽取。程序从原文中寻找日期、时间、地点和行动表达。抽取结果必须能够回到原句。例如,上门服务消息只写“地点另行确认”,输出就应保留该表达并标为待确认,不能根据家庭住址或历史记录自行补写。

一条家庭事务文本可能同时包含活动和截止时间。分类任务可以根据主要用途给出一个候选类别,信息抽取则保留多个时间字段。项目不要求把所有复杂消息强行压缩成一行确定计划;信息不足或内部冲突时,正确输出是“需要人工核对”。

二、准备文本、标签和独立测试

训练数据继续使用notices.csv,其中的“通知”泛指进入项目的事务消息;文件至少包含split、text和label三列:

split,text,label
train,5月12日14:00在社区活动室举行旧物交换活动,event
train,本月物业费最迟于5月18日17:00前完成缴纳,deadline
train,原定周四的家庭聚餐调整到周五晚上,schedule_change
test,上门检修时间另行通知,请关注后续消息,general

split明确写为train、valid或test,避免程序随机重分后难以复现实验。每类事务文本应覆盖不同措辞,不能只让deadline类都包含“截止”两个字,否则模型可能只记住一个关键词。相同模板改几个日期形成的近重复文本应放在同一数据分区。

数据卡记录文本来源、标签定义、标注者、是否去除个人信息和划分方式。标签有争议的事务文本进入待确认区;必要时由两人独立标注并记录分歧。测试集应包含简短消息、长通知、否定句、多个时间、陌生词和缺失字段。

三、与AI协同形成首版

本章的应用中的AI包括两类:小文本分类模型在本地把家庭事务文本映射为类别概率;通用语言模型可以根据任务合同提出类别和字段候选。开发与学习AI帮助学生设计标签、生成和解释完整代码、检查数据泄漏、分析错误并形成限定修改。学生负责确认标签、原文证据、隐私范围和最终是否使用。

项目结构为:

ch06-nlp-project/
├─ input/notices.csv、target.txt
├─ src/analyze_notice.py
├─ tests/cases.json
├─ output/model.keras、test_report.json、target_result.json
└─ evidence/dialogue.md、comparison.md、change.diff、regression.md、decision-card.md

与开发与学习AI协同时可提出:

任务:完成四分类家庭事务文本模型,并从目标消息中抽取原文明示的日期、时间和地点。
实现:Python与Keras/TensorFlow;训练、验证、测试由CSV的split字段决定。
要求:TextVectorization只能适配训练文本;输出全部类别概率;低分时输出unknown。
抽取边界:只保留原文匹配,不补写地点、负责人和日期。
验收:保存模型、测试明细和目标结果;覆盖歧义、否定、冲突、陌生和缺失字段。
请先检查标签定义、近重复文本和隐私,再生成完整程序。

第二节 从词元、文本表示到任务输出

一、文字怎样进入模型

计算机不能直接把一句话当作“意思”。文本首先被切分为可处理的单位,称为词元。词元可以是字、词、子词或标点。TextVectorization根据训练文本建立有限词表,再把每个词元转换成整数编号;词表外的陌生词使用专门的未知标记。

整数编号本身没有远近含义。嵌入层为每个词元学习一个较短的数值向量,使有助于完成当前分类任务的词元形成可用表示。随后,模型汇总一段事务文本中的向量信息,通过全连接层输出四个类别分数。这里的嵌入服务于本章分类任务;更一般的语义空间和语义检索将在下一章讨论。

文本分类模型可能从多个表达中学习模式,但需要带标签样本,对训练范围外的写法也会出错。关键词规则不需要训练,行为清楚,却容易漏掉同义表达。通用语言模型能按自然语言要求完成分类和抽取,但可能补全未出现的信息,而且输出会随上下文变化。三种方法要使用同一批固定家庭事务文本比较。

二、完整核心程序

下面的src/analyze_notice.py从CSV读取固定数据分区,训练一个小文本分类模型,保存测试明细,并对target.txt完成分类和透明规则抽取。运行前安装TensorFlow和NumPy。

from pathlib import Path
import csv
import json
import re

import numpy as np
import tensorflow as tf

ROOT = Path(__file__).resolve().parents[1]
INPUT = ROOT / "input"
OUT = ROOT / "output"
OUT.mkdir(exist_ok=True)
tf.keras.utils.set_random_seed(42)

EXPECTED_LABELS = ["deadline", "event", "general", "schedule_change"]
CONFIDENCE = 0.60


def load_rows():
    with (INPUT / "notices.csv").open(encoding="utf-8-sig", newline="") as file:
        rows = list(csv.DictReader(file))
    required = {"split", "text", "label"}
    if not rows or not required.issubset(rows[0]):
        raise ValueError("notices.csv必须包含split、text和label")
    cleaned = []
    for number, row in enumerate(rows, 2):
        split = row["split"].strip()
        text = row["text"].strip()
        label = row["label"].strip()
        if split not in {"train", "valid", "test"}:
            raise ValueError(f"第{number}行split无效")
        if not text or label not in EXPECTED_LABELS:
            raise ValueError(f"第{number}行文本为空或标签无效")
        cleaned.append({"split": split, "text": text, "label": label})
    return cleaned


def select(rows, split):
    part = [row for row in rows if row["split"] == split]
    if not part:
        raise ValueError(f"缺少{split}数据")
    texts = np.asarray([row["text"] for row in part], dtype=str)
    labels = np.asarray(
        [EXPECTED_LABELS.index(row["label"]) for row in part], dtype=np.int32
    )
    return texts, labels


def keyword_baseline(text):
    """透明关键词基线;无命中时返回general。"""
    rules = {
        "deadline": ["截止", "最迟", "提交"],
        "event": ["举行", "活动", "讲座", "展示"],
        "schedule_change": ["调整", "改为", "取消", "顺延"],
    }
    scores = {
        label: sum(word in text for word in words) for label, words in rules.items()
    }
    best = max(scores, key=scores.get)
    return best if scores[best] > 0 else "general"


def extract_fields(text):
    """只抽取原文明示的常见日期、时间和“地点:”字段。"""
    date_patterns = [
        r"\d{4}年\d{1,2}月\d{1,2}日",
        r"\d{1,2}月\d{1,2}日",
        r"(?:本|下)?周[一二三四五六日天]",
    ]
    dates = []
    for pattern in date_patterns:
        dates.extend(re.findall(pattern, text))
    times = re.findall(r"(?:[01]?\d|2[0-3]):[0-5]\d", text)
    place_match = re.search(
        r"(?:地点|活动地点|上课地点)[::]\s*([^,。;;\n]+)", text
    )
    place = place_match.group(1).strip() if place_match else None
    uncertainties = []
    if not dates:
        uncertainties.append("日期未明确")
    if not times:
        uncertainties.append("具体时间未明确")
    if place is None:
        uncertainties.append("地点未按明确字段给出")
    if any(word in text for word in ["另行通知", "待定", "视情况"]):
        uncertainties.append("原文包含待定表达")
    return {
        "dates": list(dict.fromkeys(dates)),
        "times": list(dict.fromkeys(times)),
        "place": place,
        "uncertainties": uncertainties,
    }


rows = load_rows()
x_train, y_train = select(rows, "train")
x_valid, y_valid = select(rows, "valid")
x_test, y_test = select(rows, "test")

vectorizer = tf.keras.layers.TextVectorization(
    max_tokens=5000,
    output_mode="int",
    output_sequence_length=80,
)
vectorizer.adapt(x_train)

model = tf.keras.Sequential(
    [
        tf.keras.layers.Input(shape=(), dtype=tf.string),
        vectorizer,
        tf.keras.layers.Embedding(5000, 32, mask_zero=True),
        tf.keras.layers.GlobalAveragePooling1D(),
        tf.keras.layers.Dense(24, activation="relu"),
        tf.keras.layers.Dropout(0.2),
        tf.keras.layers.Dense(len(EXPECTED_LABELS), activation="softmax"),
    ]
)
model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)
early_stop = tf.keras.callbacks.EarlyStopping(
    monitor="val_loss", patience=3, restore_best_weights=True
)
model.fit(
    x_train,
    y_train,
    validation_data=(x_valid, y_valid),
    epochs=20,
    batch_size=16,
    callbacks=[early_stop],
    verbose=2,
)
model.save(OUT / "model.keras")

test_scores = model.predict(x_test, verbose=0)
test_rows = []
for text, truth, scores in zip(x_test, y_test, test_scores):
    top = int(np.argmax(scores))
    test_rows.append(
        {
            "text": str(text),
            "truth": EXPECTED_LABELS[int(truth)],
            "model_label": EXPECTED_LABELS[top],
            "model_score": float(scores[top]),
            "keyword_label": keyword_baseline(str(text)),
            "correct": bool(top == int(truth)),
        }
    )
test_report = {
    "count": len(test_rows),
    "model_correct": sum(row["correct"] for row in test_rows),
    "keyword_correct": sum(
        row["keyword_label"] == row["truth"] for row in test_rows
    ),
    "rows": test_rows,
}
(OUT / "test_report.json").write_text(
    json.dumps(test_report, ensure_ascii=False, indent=2), encoding="utf-8"
)

target = (INPUT / "target.txt").read_text(encoding="utf-8").strip()
if not target:
    raise ValueError("target.txt为空")
scores = model.predict(np.asarray([target]), verbose=0)[0]
top = int(np.argmax(scores))
model_label = EXPECTED_LABELS[top] if scores[top] >= CONFIDENCE else "unknown"
fields = extract_fields(target)
result = {
    "raw_text": target,
    "model_probabilities": {
        label: float(score) for label, score in zip(EXPECTED_LABELS, scores)
    },
    "model_label": model_label,
    "keyword_baseline": keyword_baseline(target),
    "extracted_fields": fields,
    "status": "needs_human_review" if fields["uncertainties"] else "candidate",
    "notice": "分类和抽取均为候选,必须回到事务原文核对。",
}
(OUT / "target_result.json").write_text(
    json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(result, ensure_ascii=False, indent=2))

程序中的小模型负责文本分类,extract_fields只实现一组透明抽取规则。规则没有匹配到,不代表原文一定没有该信息;它可能使用了程序尚未覆盖的表达。将规则结果与模型分类分开保存,可以判断错误发生在类别判断还是字段抽取。

三、比较规则、小模型与通用语言模型

比较时使用同一组封存家庭事务文本和同一评价表。关键词规则记录类别是否正确、哪些同义表达漏掉;小模型记录分类正确数、各类别错误和低置信度样例;通用语言模型记录类别、字段是否有原文依据、是否补写不存在的信息。不能用“语言更自然”代替任务评价。

通用语言模型的任务合同可以写成:

只处理给定家庭事务文本。输出类别候选、日期、时间、地点、行动和不确定项。
每个非空字段同时复制支持它的原文片段。
原文未写就使用null;不得根据常识补写负责人、地点和日期。
遇到多种时间、取消与原安排并存或相互冲突时,保留各说法并标记人工核对。
输出只形成预览候选;未经人确认,不写入日历、不发送消息。

语言模型可以覆盖更多表达,但输出具有不确定性;小模型可本地、稳定运行,但受标签和训练数据限制;规则透明,却覆盖有限。实际选择要考虑任务规模、隐私、离线要求、错误后果和维护能力。

第三节 测试歧义、遗漏、冲突与抽取边界

一、建立固定失败测试集

类型 家庭事务文本示例 要暴露的问题 期望行为
正常 日期、时间、地点和行动明确 主流程是否完整 输出候选并保留原文
边界 “周五晚饭后”“上门地点另行确认” 相对或待定表达 不擅自变成绝对值
陌生 新活动名称、少见专业词、混合类型 标签集合是否足够 显示概率,必要时unknown
故障 文本为空、编码损坏、模型文件不可用 能否安全停止 保留输入并说明原因
冲突 原消息写周三上门,补充消息写改到周四 是否覆盖旧信息 并列保留,交给人确认

否定表达需要单独测试。例如“旧物交换活动不在社区活动室举行,地点另行通知”不能抽取为place=社区活动室。又如“原定周三的上门服务取消,周四是否改约待定”,程序不能只抓住“周三”和“周四”就生成两个确定事项。词元和模型能处理文字模式,但不保证完整理解事务状态。

遗漏也不能通过猜测补齐。若消息未写联系人,候选字段保持空值;若只写“下午”,就保留原文并标记具体时间未明确。模型输出整齐的表格,不意味着这些字段都来自原文。

受控小案例:建立家庭健康资料与就医准备助手。 这一案例只复用“从原文抽取候选字段并保留证据”的方法,不把上面的四分类模型改造成医疗模型。课堂使用下面两条完全虚构、已经脱敏的材料:

[H-01|2026-05-12|第1页] 家庭观察记录:体温37.2 ℃;未出现皮疹。
[H-02|2026-05-13|第2页] 模拟用药清单:模拟药A,每次10 mg,每日1次;是否继续使用待向医生确认。

候选表应把2026-05-12、37.2与℃一起保存,并用来源锚点H-01|第1页支持这些字段;“未出现皮疹”必须保留否定词,不能改写成“出现皮疹”。10 mg不能丢失单位,且“是否继续使用”只能进入待确认项并指向H-02|第2页。系统不得依据这些片段判断疾病、推荐或停用药物,也不得判断是否为急症;这些决定必须交给医生或相应专业人员。若日期、单位、否定词或来源锚点缺失,整条结果保持待核对,不进入确定清单。

二、根据错误限定修改

固定测试中,规则把“旧物交换活动不在社区活动室举行”抽取成了社区活动室。修改请求应指出真实错误和允许范围:

错误样例:原文“旧物交换活动不在社区活动室举行,地点另行通知”。
实际结果:place被填为“社区活动室”。
期望结果:place为null,并增加“地点待定”。
只允许修改:extract_fields中的地点抽取与否定检查。
禁止修改:训练数据、分类模型、原始文本和其他测试。
完成后:重跑正常地点、地点待定、否定地点、多个地点和空文本样例。

开发与学习AI可能建议更换更大的语言模型或重写整个项目,但这不能直接证明问题得到解决。应先审查最小代码差异,再使用原测试集回归。修复否定句后,正常的“地点:社区活动室”仍应正确抽取,不能因规则过严而全部变成空值。

三、形成抽取边界和使用判断

判断卡要记录:事务文本来源和标签范围;训练与测试是否独立;哪些字段由规则抽取,哪些由模型提出;相对日期、否定、取消和冲突怎样处理;谁在行动或日历写入前回看原文并确认预览;模型或文本损坏时如何停止。

合理使用方式是:对低风险、来源明确的家庭事务文本生成分类和候选清单,帮助发现遗漏;任何日期、时间、地点和取消状态必须回到原文核对;相对时间缺少基准、消息互相冲突、字段不足或模型低置信度时不进入确定计划。少量消息直接人工阅读更快;格式固定时表单和规则可能优于模型。家庭健康资料只能按上述受控方式整理来源明确的候选字段,不能把文本整理升级为医疗判断。

本章小结

自然语言处理把文字转换成机器可以处理的词元和数值表示,再完成分类、抽取或生成等任务。文本分类为整段家庭事务文本选择候选类别,信息抽取寻找日期、时间和地点等字段,语言生成根据上下文产生新文字。结构化输出便于检查,但不自动保证事实正确。

关键词规则、小文本模型和通用语言模型各有能力边界。应用中的AI可以提出类别与字段候选,开发与学习AI协助构建和修订程序,人负责标签、原文证据、冲突处理和最终行动。歧义、否定、遗漏、陌生表达与故障测试,使“机器处理文本”从一次成功演示变成可判断的技术实践。

关键术语

目标测试

一、单项选择题

  1. 把整段家庭事务文本判断为“活动”“截止事项”等类别,属于( )。
    A.文本分类 B.图像分割 C.语音合成 D.设备控制
  2. TextVectorization只在训练文本上建立词表,主要是为了( )。
    A.增加文本长度 B.避免验证和测试信息进入训练过程 C.替代人工标签 D.自动补写地点
  3. 原文写“地点另行通知”时,最合适的输出是( )。
    A.根据往年活动填写礼堂 B.删除通知 C.地点保持空值并标记待确认 D.让模型随机选择
  4. 下列关于关键词规则、小模型和通用语言模型的说法,正确的是( )。
    A.规则能理解所有同义表达 B.小模型不需要标签 C.通用语言模型输出无需核对 D.三者应在同一测试集上比较任务结果

二、判断并改错

  1. 通用语言模型输出了合法且整齐的JSON,说明其中每个字段都一定来自家庭事务原文。请判断并改正。
  2. 测试集中的家庭事务文本只要没有参加模型参数训练,就可以反复用于调标签和选模型,仍算独立测试。请判断并改正。

三、简答与操作题

  1. 说明词元、整数编号、嵌入表示和类别概率之间的关系。
  2. 对消息“原定周三下午的社区讲座取消,是否改到周四另行通知”分别给出分类候选、可抽取字段和必须人工确认的内容。
  3. 设计一次规则、小文本模型和通用语言模型的对照实验,写出固定项、评价项和结论边界。
  4. 对模拟材料“[H-01|2026-05-12|第1页] 体温37.2 ℃;未出现皮疹”设计候选抽取结果,写出来源锚点、日期、数值与单位、否定信息、待确认项和禁止输出。

答案编号:A1-6-01—A1-6-10。完整答案和评价要点统一放入书后“目标测试参考答案”。