政府采购文本量逐年增长,投诉质疑内容的自动化筛查是一个很实际的需求。手工翻阅招标文件、质疑函、投诉书来定位“倾向性条款”“围标串标表述”“排斥潜在供应商”等指控性语言,耗时且容易漏。本文围绕一个级联式 NLP Pipeline 方案展开,先通过无监督方法做候选召回,再用有监督模型做精细分类,兼顾标注成本、模型精度与可解释性,适合做文本审核、合规分析、招标文件筛查相关项目的开发者参考。
1. 背景与核心概念
1.1 为什么要在政府采购文本中识别指控性语言
政府采购公开文本中包含大量非结构化信息,例如招标公告、招标文件、评标报告、质疑函、投诉书、中标公告等。这些文本中经常出现对采购行为合法性的质疑,比如:
- 招标文件参数指向特定品牌;
- 资格条件存在歧视性、倾向性;
- 投标人之间存在围标、串标嫌疑;
- 评标过程存在利益冲突或程序违规。
这些语言在文本中往往不是直接声明,而是以“疑似”“涉嫌”“可能”“不排除”等委婉表达出现。人工检测需要阅读大量段落并做语义判断,不仅效率低,还容易受到个人经验差异的影响。通过 NLP 技术自动识别指控性语言,可以把待审文本按风险等级排序,缓解人工审核压力。
本文的 Pipeline 目标不是判断政府采购活动本身是否违规,而是识别“文本中是否存在指控性语言”。这个边界需要首先明确,否则后续模型设计容易走偏。
1.2 无监督与有监督组合的动机
纯有监督方案的问题在于标注样本难以获取。政府采购文本中的指控性语言属于长尾分布,正样本占比低、表述形式多样,人工标注成本很高。纯无监督方案虽然可以省去标注,但规则和聚类的结果通常不够精细,难以满足准确率要求。
一个可行的思路是级联组合:
- 无监督阶段利用关键词规则、TF-IDF 向量化、KMeans 聚类,把海量原始文本压缩成少量候选段落;
- 有监督阶段在相对较小的候选集上训练分类模型,判断该段落是否包含指控性语言。
这种设计既降低了对大规模标注数据的依赖,又保留了有监督分类的精度。
1.3 什么是级联 Pipeline
级联 Pipeline(Cascaded Pipeline)指的是将多个处理模块按顺序串联,前一个模块的输出作为后一个模块的输入。在 NLP 场景中,常见的 Pipeline 形式包括:
- 文本清洗 → 分词 → 特征提取 → 建模;
- 召回 → 过滤 → 排序 → 输出;
- 无监督粗筛 → 有监督精排。
本文的方案属于第三种。级联的好处是职责单一、便于替换、中间结果可审计。比如后续希望用预训练语言模型替换有监督分类器,只需要修改第二阶段的代码,不需要动第一阶段。
2. 任务定义与数据准备
2.1 任务边界
本文要做的任务可以形式化描述为:
输入一段政府采购相关文本,输出一个二分类标签label:
1表示文本中包含指控性语言;0表示文本中不包含指控性语言。
更细粒度可以把指控性语言进一步分为多个子类型,例如“参数倾向性”“围标串标”“程序违规”“利益冲突”。本文先用二分类演示完整流程,子类型可以在有监督阶段扩展为多分类任务。
2.2 数据字段与示例
实际项目中原始数据可能来自多个异构系统,建议统一落成 CSV 格式,至少包含以下字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| doc_id | str | 文档唯一标识 |
| source_type | str | 文本来源,如质疑函、投诉书、招标文件 |
| text | str | 待分析的文本段落 |
| label | int | 是否有指控性语言,0/1,用于有监督训练 |
下面是一个标注样本示例:
doc_id,source_type,text,label D001,质疑函,本项目招标文件参数明显指向特定品牌,涉嫌排斥潜在供应商。,1 D002,中标公告,评标过程符合法定程序,各投标人报价均在预算范围内。,0 D003,投诉书,两家投标人投标文件制作机器码一致,疑似围标串标。,1 D004,招标文件,供应商须在合同签订后30日内完成交付,逾期按合同约定处理。,02.3 数据标注难点
标注指控性语言需要注意几个容易被忽略的问题:
- 委婉表达:指控不一定使用激烈词,更多是“疑似”“涉嫌”“不排除”这类词;
- 否定与反事实:例如“不存在倾向性”也包含关键词“倾向性”,但语义是否定;
- 上下文依赖:一个段落单独看可能是中性的,但结合前文会构成指控;
- 标注一致性:不同标注员对“是否算指控”可能理解不同,需要事先制定标注规范并计算标注一致性。
3. Cascaded Pipeline 整体架构设计
3.1 两阶段架构
整个 Pipeline 可以分为两大阶段:
原始采购文本 │ ▼ ┌────────────────────┐ │ 阶段一:无监督召回 │ │ 规则命中 + 聚类过滤 │ └────────────────────┘ │ 候选文本 ▼ ┌────────────────────┐ │ 阶段二:有监督分类 │ │ TF-IDF + LR/SVM │ └────────────────────┘ │ ▼ 指控性语言结果第一阶段的目标是“不漏”,第二阶段的目标是“不错”。两个阶段的目标不同,因此评估指标也应该分开看。
3.2 每一阶段的职责
第一阶段(无监督):
- 文本清洗与中文分词;
- 基于指控性关键词表的规则召回;
- 召回结果做 TF-IDF 向量化;
- 使用 KMeans 聚类过滤明显与指控主题无关的文本。
第二阶段(有监督):
- 基于人工标注数据训练分类器;
- 对第一阶段召回的候选文本做最终判定;
- 输出结构化结果,供审核人员复核。
3.3 为什么先无监督后有监督
如果只用有监督模型处理全部政府采购文本,会遇到两个问题。第一,正负样本极不平衡,大量中性文本会淹没少数指控性表达,模型容易偏向多数类。第二,全量文本进入分类器会导致推理耗时和成本上升,尤其在语料达到几十万段甚至上百万段时。
级联设计让无监督阶段先做粗粒度筛选。规则命中保证了高召回漏斗,聚类过滤剔除明显噪声,而有监督模型只需要处理小规模候选集,既减少了样本不平衡的干扰,也提升了推理效率。
4. 阶段一:无监督线索挖掘
4.1 文本预处理
中文政府采购文本通常包含大量空格、特殊字符、英文缩写、数字单位。预处理阶段需要做:
- 去除多余空白字符;
- 保留中文、英文、数字和基础标点;
- 将文本转成干净字符串,方便后续分词和关键词匹配。
这里使用 jieba 做中文分词。需要注意 jieba 对领域词汇的分词效果依赖于词典,建议额外准备采购领域的自定义词典,例如“围标串标”“倾向性”“量身定制”等词条。
4.2 关键词规则召回
无监督阶段最直观的方法是构建指控性语言关键词表。示例词表如下:
围标 串标 倾向性 量身定制 指定品牌 排斥潜在供应商 利益冲突 关联关系 不公正 歧视性 围标串标 量身定做 疑似串通 操纵评标对每段文本统计命中关键词的数量,命中数量达到阈值则进入候选集。阈值越低,召回越高,但也会引入更多噪声;阈值越高,过滤越强,但可能漏掉部分委婉表达。
这一步的代码逻辑是对每段文本做子串匹配,计算命中次数。
4.3 聚类过滤噪声
关键词召回会带来大量“伪指控”文本,比如“本项目不存在倾向性条款”这句话命中了“倾向性”,但实际表达的是否定含义。聚类过滤的思路是:根据文本语义将候选集分成若干簇,如果某个簇整体的关键词命中率和语义特征与典型指控文本差异很大,可以视为噪声簇。
具体实现是使用 TF-IDF 向量化后做 KMeans 聚类,然后计算每个簇的平均关键词命中次数,过滤掉明显低于整体均值的簇。这个方法虽然简单,但在实际项目中能明显减少有监督阶段的无效样本量。
5. 阶段二:有监督分类模型
5.1 特征工程
有监督阶段输入的是候选文本,特征可以分成两类。
第一类是基础文本特征,最常用是 TF-IDF。为了保留短语信息,可以加入ngram_range=(1, 2),让“倾向性”“围标串标”这类双词组合成为独立特征。
第二类是规则辅助特征,比如:
- 关键词命中次数;
- 是否包含“疑似”“涉嫌”“不排除”“或许”等不确定性表达;
- 是否包含“招标文件”“评标委员会”“投标人”“中标”等主体名词;
- 文本长度。
规则辅助特征可以帮助模型学习“委婉指控”模式,而不仅仅依赖情感词或极端词。
5.2 模型选型与训练
有监督阶段不需要刻意追求复杂模型。政府采购文本分类通常是长文本、稀疏特征场景,线性模型表现稳定且可解释性更强。这里以逻辑回归(Logistic Regression)为例,实际项目中也可以对比 LinearSVC、朴素贝叶斯或随机森林。
训练时需要注意两个问题:
- 类别不平衡:设置
class_weight="balanced",让模型更关注少数类; - 固定随机种子:保证实验结果可复现,也方便后续比较不同特征方案。
5.3 评估指标
对于这种任务,不能只看准确率,因为负样本占多数,模型即使全部预测为 0 也能获得很高准确率。应重点关注:
- 精确率(Precision):模型判定为指控性语言的文本中,确实是指控性语言的比例;
- 召回率(Recall):所有真正的指控性语言文本中,模型正确找出的比例;
- F1 值:精确率和召回率的调和平均;
- 混淆矩阵:观察误报和漏报的具体分布。
在审核场景中,通常倾向于优先保证召回率,宁可多召回一些候选给人工复核,也不要漏掉真正的风险文本。
6. 完整代码实战
6.1 项目结构
为了便于演示,本文使用以下项目结构。实际项目中可以按团队规范调整目录组织方式。
procurement_nlp/ ├── config.py ├── preprocessing.py ├── stage1_unsupervised.py ├── stage2_supervised.py ├── pipeline.py ├── data/ │ ├── raw_docs.csv │ ├── labeled_samples.csv │ └── accusation_keywords.txt └── output/其中raw_docs.csv是待处理的原始文本,labeled_samples.csv是人工标注的训练数据,accusation_keywords.txt是指控性关键词表。下面逐一给出各文件的核心代码。
6.2 配置模块 config.py
# 文件路径:procurement_nlp/config.py import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # 数据路径 RAW_DATA_PATH = os.path.join(BASE_DIR, "data", "raw_docs.csv") LABELED_DATA_PATH = os.path.join(BASE_DIR, "data", "labeled_samples.csv") KEYWORD_FILE = os.path.join(BASE_DIR, "data", "accusation_keywords.txt") # 输出目录 OUTPUT_DIR = os.path.join(BASE_DIR, "output") os.makedirs(OUTPUT_DIR, exist_ok=True) # 无监督召回参数 RECALL_THRESHOLD = 1 # 关键词命中数量阈值 CLUSTER_NUM = 8 # KMeans 聚类数量 CLUSTER_SAMPLE_RATIO = 0.6 # 簇过滤比例 # 有监督模型参数 TEST_SIZE = 0.2 RANDOM_STATE = 42 CLASS_WEIGHT = "balanced" TFIDF_MAX_FEATURES = 10000把超参数集中放在config.py里,后续调整阈值和模型参数时不需要修改主流程代码,这是工程化的基本习惯。
6.3 预处理模块 preprocessing.py
# 文件路径:procurement_nlp/preprocessing.py import re import pandas as pd import jieba STOP_WORDS = set() # 建议加载停用词表,示例中先保留空集合 # with open("data/stopwords.txt", "r", encoding="utf-8") as f: # STOP_WORDS = set(line.strip() for line in f if line.strip()) def clean_text(text: str) -> str: """清洗文本:去除多余空白和特殊符号,保留中英文、数字和基础标点。""" if not isinstance(text, str): return "" text = re.sub(r"\s+", " ", text) text = re.sub(r"[^\u4e00-\u9fa5A-Za-z0-9,。!?、;:""''()()%]", " ", text) return text.strip() def tokenize(text: str) -> list: """中文分词,过滤停用词和单字。""" words = jieba.lcut(clean_text(text)) return [w for w in words if w.strip() and len(w) > 1 and w not in STOP_WORDS] def load_raw_data(path: str) -> pd.DataFrame: """加载原始文本并生成清洗列。""" df = pd.read_csv(path) df["clean_text"] = df["text"].apply(clean_text) df["tokens"] = df["text"].apply(tokenize) return df预处理阶段需要注意一个细节:不要把“不排除”“不明显”中的“不”直接过滤掉,因为这类否定词对判断指控性语言的置信度有重要作用。停用词表应该谨慎设计,而不是直接套用通用中文停用词表。
6.4 无监督召回 stage1_unsupervised.py
# 文件路径:procurement_nlp/stage1_unsupervised.py from typing import List import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.cluster import KMeans def load_keywords(path: str) -> List[str]: """加载指控性语言关键词表。""" with open(path, "r", encoding="utf-8") as f: return [line.strip() for line in f if line.strip()] def rule_recall(df: pd.DataFrame, keywords: List[str], threshold: int = 1) -> pd.DataFrame: """基于关键词命中次数做规则召回。""" def hit_count(text: str) -> int: return sum(1 for kw in keywords if kw in text) df["keyword_hits"] = df["clean_text"].apply(hit_count) return df[df["keyword_hits"] >= threshold].copy() def cluster_filter(df: pd.DataFrame, n_clusters: int = 8, sample_ratio: float = 0.6) -> pd.DataFrame: """通过 TF-IDF + KMeans 聚类过滤噪声文本。""" vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2)) X = vectorizer.fit_transform(df["clean_text"]) km = KMeans(n_clusters=n_clusters, random_state=42, n_init=10) df["cluster"] = km.fit_predict(X) # 计算每个簇的平均关键词命中次数,过滤低于阈值的簇 cluster_stats = df.groupby("cluster")["keyword_hits"].mean() global_mean = df["keyword_hits"].mean() keep_clusters = cluster_stats[ cluster_stats >= global_mean * sample_ratio ].index.tolist() return df[df["cluster"].isin(keep_clusters)].copy()cluster_filter是一个启发式过滤方法,思路是:如果某个簇整体关键词命中次数远低于全局平均水平,说明该簇内文本很可能只是偶然命中关键词,并不是真正的指控性语言。这个方法的核心价值在于过滤“伪命中”。
6.5 有监督模型 stage2_supervised.py
# 文件路径:procurement_nlp/stage2_supervised.py import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, confusion_matrix def build_model(df: pd.DataFrame, random_state: int = 42): """ 训练有监督分类模型。 输入 DataFrame 至少包含 clean_text 和 label 两列。 """ X_train, X_test, y_train, y_test = train_test_split( df["clean_text"], df["label"], test_size=0.2, random_state=random_state, stratify=df["label"], ) vectorizer = TfidfVectorizer( max_features=10000, ngram_range=(1, 2), ) X_train_vec = vectorizer.fit_transform(X_train) X_test_vec = vectorizer.transform(X_test) model = LogisticRegression( class_weight="balanced", max_iter=1000, random_state=random_state, ) model.fit(X_train_vec, y_train) y_pred = model.predict(X_test_vec) print(classification_report(y_test, y_pred)) print("混淆矩阵:") print(confusion_matrix(y_test, y_pred)) return vectorizer, model有监督部分采用了逻辑回归。如果标注数据规模较大,可以尝试LinearSVC或RandomForestClassifier做对比。需要注意,train_test_split中使用了stratify=df["label"],保证训练集和测试集中正负样本比例一致,避免因随机划分导致测试集没有正样本。
6.6 串联运行 pipeline.py
# 文件路径:procurement_nlp/pipeline.py import pandas as pd from preprocessing import clean_text, load_raw_data from stage1_unsupervised import load_keywords, rule_recall, cluster_filter from stage2_supervised import build_model import config def run_inference(text: str, vectorizer, model, keywords, threshold=1): """对单条文本执行完整推理流程。""" clean = clean_text(text) hits = sum(1 for kw in keywords if kw in clean) if hits < threshold: return {"label": 0, "stage": "unsupervised_reject"} vec = vectorizer.transform([clean]) pred = model.predict(vec)[0] return {"label": int(pred), "stage": "supervised_predict"} if __name__ == "__main__": # 1. 无监督召回 raw_df = load_raw_data(config.RAW_DATA_PATH) keywords = load_keywords(config.KEYWORD_FILE) candidates = rule_recall(raw_df, keywords, threshold=config.RECALL_THRESHOLD) candidates = cluster_filter( candidates, n_clusters=config.CLUSTER_NUM, sample_ratio=config.CLUSTER_SAMPLE_RATIO, ) # 2. 有监督模型训练 labeled_df = pd.read_csv(config.LABELED_DATA_PATH) labeled_df["clean_text"] = labeled_df["text"].apply(clean_text) vectorizer, model = build_model(labeled_df, random_state=config.RANDOM_STATE) # 3. 对候选集预测 candidates["pred_label"] = model.predict( vectorizer.transform(candidates["clean_text"]) ) result = candidates[candidates["pred_label"] == 1] print(f"候选数量:{len(candidates)},指控性语言数量:{len(result)}") output_path = config.OUTPUT_DIR + "/predictions.csv" result[["doc_id", "source_type", "text", "pred_label"]].to_csv( output_path, index=False, encoding="utf-8-sig" ) print(f"结果已保存至:{output_path}")这里的主流程严格遵循先无监督、后有监督的顺序。无监督阶段先排除大量中性文本,有监督阶段在候选集上做精细分类。单条推理函数run_inference展示了模型部署后如何对新文本进行预测。
6.7 运行与预期输出
安装依赖后,在项目根目录执行:
pip install pandas scikit-learn jieba python pipeline.py如果标注样本充足,终端会输出类似下面的分类报告:
precision recall f1-score support 0 0.88 0.84 0.86 50 1 0.72 0.78 0.75 30 accuracy 0.82 80 macro avg 0.80 0.81 0.80 80 weighted avg 0.82 0.82 0.82 80 混淆矩阵: [[42 8] [ 6 24]]需要强调的是,上面只是演示预期效果,实际指标完全取决于标注数据质量和语料分布。如果数据集中只有几十条正样本,任何模型都很难达到理想效果,此时应该优先扩充标注数据而不是更换模型。
7. 常见问题与排查思路
7.1 问题排查表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| jieba 分词结果把领域词切碎 | 缺少采购领域自定义词典 | 添加自定义词典,例如“围标串标”“倾向性” |
| 关键词召回过量,候选集太大 | 关键词太宽泛,阈值太低 | 提高RECALL_THRESHOLD,或增加聚类过滤强度 |
| 有监督模型误报多 | 正负样本不平衡或标注标准不一致 | 检查标注规范,使用class_weight="balanced" |
| 明明有关键词但被判为负样本 | 否定表达干扰 | 在特征中加入否定词特征,或用规则覆盖“不存在……”“未发现……” |
| 多次运行结果不一致 | 未固定随机种子 | 设置random_state,并固定 KMeans 的n_init |
| 使用预训练模型后效果反而变差 | 领域语料与预训练语料分布差异大 | 先在采购文本上做领域自适应预训练或微调 |
7.2 高发问题详细复盘
在实践中,“关键词命中但语义否定”是最容易踩坑的一类问题。比如下面这句话:
本项目招标文件不存在倾向性条款,评标过程公平公正。“倾向性”命中关键词,但语义是否定。规则召回阶段无法处理这种问题,聚类过滤也只能从统计层面淡化,最终还是要靠有监督模型学习“不存在”“未发现”“未体现”等否定表达与“倾向性”的共现模式。
另一个常见问题是中文分词引起的召回偏差。默认 jieba 可能把一个完整的领域词切开,导致关键词无法匹配。解决方案是在项目初始化时加载自定义词典:
import jieba jieba.load_userdict("data/procurement_dict.txt")自定义词典中每行一个词,可以配置词频和词性,例如:
围标串标 10 n 倾向性条款 10 n 排斥潜在供应商 5 n8. 最佳实践与工程建议
8.1 数据与标注管理
指控性语言识别本质上是文本分类任务,数据质量决定了效果上限。建议项目启动时先制定标注规范,明确以下几点:
- 什么是“指控性语言”,什么不算;
- 委婉表达是否计入;
- 否定表达如何处理;
- 一段文本同时包含多个指控子类型时如何标记。
标注阶段建议至少两人独立标注,并计算 Cohen‘s Kappa 一致性指标。如果一致性低于 0.6,需要重新讨论标注规范,而不是急着训练模型。
8.2 模型可解释性与审计
政府采购文本审核涉及合规场景,模型输出需要能被复核人员理解和信任。建议:
- 使用逻辑回归等线性模型时,保存特征词权重,便于解释预测依据;
- 对每条高风险预测,输出命中关键词、Top 特征词以及置信度;
- 建立人工复核闭环,把复核结果回填到训练集中,持续迭代。
如果后续替换为 BERT 等深层模型,建议引入 SHAP 或 LIME 做局部解释。不要只输出一个 0/1 标签,审核场景中“为什么判定为指控”比“判定结果”更重要。
8.3 性能与生产部署
级联 Pipeline 在生产环境中的性能可以从两个角度优化。
第一,无监督阶段的 TF-IDF 向量化和 KMeans 模型需要保存到本地,建议使用joblib或pickle序列化,避免每次推理重新训练:
import joblib joblib.dump(vectorizer, "output/tfidf_vectorizer.pkl") joblib.dump(model, "output/lr_model.pkl") joblib.dump(km, "output/kmeans_model.pkl")第二,大批量处理时可以采用批处理方式,一次性对多篇文档做无监督召回,再对有监督分类器做批量预测。单条循环调用会浪费大量时间在重复的数据加载与模型调度上。
8.4 合规与安全边界
政府采购文本通常涉及公开信息和敏感商业信息。处理时需要遵守法律规定和单位内部安全要求:
- 仅处理已授权的公开采购文本或本单位内部合规数据;
- 对数据做好脱敏处理,去除个人身份信息;
- 模型训练和推理环境应遵循最小权限原则;
- 禁止将内部采购数据用于未经授权的场景或上传到不受管控的外部平台。
涉及模型上线时,建议先在测试环境验证,并保留旧模型回滚能力,避免因新模型误报率波动影响业务。
9. 总结与学习路线
本文围绕政府采购文本中的指控性语言识别,拆解了一个级联式无监督-有监督 NLP Pipeline 的完整实现。核心思路是先通过关键词规则召回候选文本,再用 TF-IDF 加聚类过滤噪声,最后用逻辑回归模型在候选集上做精细分类。这个方案在工作量、标注成本和模型效果之间取得了一个相对平衡的点。
接下来可以继续深入的方向包括:
- 引入预训练语言模型,例如将第二阶段的逻辑回归替换为 BERT 分类模型,对比效果差异;
- 将二分类扩展为多分类,区分“参数倾向性”“围标串标”“程序违规”等指控子类型;
- 加入时间维度,对同一采购项目的历史文本做交叉分析,发现跨文档关联线索;
- 在人工审核闭环中引入主动学习,让模型优先选择最值得标注的样本。
实际项目中优先关注三个方面:标注数据质量、类别不平衡处理、模型可解释性。先把这三件事做好,再考虑更换复杂模型或增加更多特征,否则容易陷入“调参改善零点几个点”的循环。本文的方案可以直接作为基础框架使用,代码结构上把无监督和有监督阶段做了模块化拆分,替换或扩展都相对容易。