第6章 自然语言处理:让机器处理文本任务
本章技术主题: 自然语言处理——把文字转换为可学习的表示和任务输出。
本章技术实验: 对家庭事务文字做分类和字段抽取,并保留原文证据。
家庭项目: “把零散家庭事务变成可执行计划”是主项目;“建立家庭健康资料与就医准备助手”是模拟脱敏的受控案例。
跨章位置: 本章只生成候选事项和待确认项;日历等外部写入留到第12章讨论,并必须预览确认。
技术主线: 家庭事务文本 → 词元与文本表示 → 分类/信息抽取 → 候选字段 → 原文核对 → 边界测试。
学习目标
学完本章,你将能够:
- 区分文本分类、信息抽取和语言生成三类自然语言处理任务;
- 用生活语言解释词元、词表、嵌入表示和文本分类概率;
- 运行一个使用Keras/TensorFlow完成家庭事务文本分类的小模型,并用规则抽取明确字段;
- 比较关键词规则、小文本模型和通用语言模型的能力与限制;
- 用歧义、遗漏、否定、冲突和陌生表达测试结果,决定哪些字段可以进入候选清单。
项目导入
家庭事务可能来自社区通知、缴费提醒、预约短信、家校消息和家庭成员留言,表达方式并不统一。有的公布活动,有的提醒截止时间,有的临时调整安排,还有的只是一般说明。学生真正需要的往往不是一段更流畅的摘要,而是知道“这是什么类型的事项”“可能需要做什么”“时间和地点是否明确”“哪些信息仍待确认”。
本章直接使用已经转成文字的家庭事务材料,先训练一个小文本分类模型,把文本分为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协助构建和修订程序,人负责标签、原文证据、冲突处理和最终行动。歧义、否定、遗漏、陌生表达与故障测试,使“机器处理文本”从一次成功演示变成可判断的技术实践。
关键术语
- 自然语言处理:让计算机分析、表示或生成人类语言的技术领域。
- 词元:文本进入模型后处理的基本单位,可以是字、词、子词或标点。
- 词表:模型能够直接编号的一组词元集合。
- 嵌入表示:把离散词元映射为可学习数值向量的方法。
- 文本分类:为一段文本预测一个或多个类别的任务。
- 信息抽取:从原文中寻找人物、时间、地点、行动等指定信息的任务。
- 原文证据:支持某个候选字段、可以回到事务原文核对的文字片段。
- 低置信度拒绝:最高类别分数未达到接受条件时输出未知或要求复核。
- 数据泄漏:近重复文本或测试信息不恰当地进入训练过程,使评价虚高。
目标测试
一、单项选择题
- 把整段家庭事务文本判断为“活动”“截止事项”等类别,属于( )。
A.文本分类 B.图像分割 C.语音合成 D.设备控制 TextVectorization只在训练文本上建立词表,主要是为了( )。
A.增加文本长度 B.避免验证和测试信息进入训练过程 C.替代人工标签 D.自动补写地点- 原文写“地点另行通知”时,最合适的输出是( )。
A.根据往年活动填写礼堂 B.删除通知 C.地点保持空值并标记待确认 D.让模型随机选择 - 下列关于关键词规则、小模型和通用语言模型的说法,正确的是( )。
A.规则能理解所有同义表达 B.小模型不需要标签 C.通用语言模型输出无需核对 D.三者应在同一测试集上比较任务结果
二、判断并改错
- 通用语言模型输出了合法且整齐的JSON,说明其中每个字段都一定来自家庭事务原文。请判断并改正。
- 测试集中的家庭事务文本只要没有参加模型参数训练,就可以反复用于调标签和选模型,仍算独立测试。请判断并改正。
三、简答与操作题
- 说明词元、整数编号、嵌入表示和类别概率之间的关系。
- 对消息“原定周三下午的社区讲座取消,是否改到周四另行通知”分别给出分类候选、可抽取字段和必须人工确认的内容。
- 设计一次规则、小文本模型和通用语言模型的对照实验,写出固定项、评价项和结论边界。
- 对模拟材料“[H-01|2026-05-12|第1页] 体温37.2 ℃;未出现皮疹”设计候选抽取结果,写出来源锚点、日期、数值与单位、否定信息、待确认项和禁止输出。
答案编号:A1-6-01—A1-6-10。完整答案和评价要点统一放入书后“目标测试参考答案”。