Snorkel 弱监督学习:编写标注函数生成训练数据

FreeGuideOnline 最新 2026-06-27

Snorkel 弱监督学习实战:编写标注函数生成训练数据

在监督学习的实际项目中,最大的瓶颈往往不是模型调优,而是缺乏大规模高质量的标注数据。人工标注成本高、周期长,而弱监督学习(Weak Supervision)正是为破解这一难题而生。Snorkel 是弱监督学习领域最具影响力的开源框架,由斯坦福大学开发并维护。本教程将带你从零开始,理解 Snorkel 的核心思想,并通过编写标注函数(Labeling Functions, LFs)自动生成训练数据,最终构建一个可用的文本分类模型。

什么是弱监督与 Snorkel

传统监督学习依赖大量手工标注样本,而弱监督学习允许我们利用多种较低质量、成本更低的监督信号来合成高效的训练标签。这些信号可能来自:

  • 启发式规则:关键词匹配、正则表达式。
  • 外部知识库:词典、已有数据库。
  • 预训练模型:零样本(zero-shot)分类器、远程监督。
  • 众包标注:非专家标注者给出的带噪声标签。

Snorkel 的核心工作流不是让单一弱信号直接主导模型训练,而是将成百上千个标注函数的输出通过一个标签模型(Label Model)融合,自动估计每个弱标注的准确性并生成概率噪声标签。最终,这些概率标签被用于训练下游的最终模型(例如分类器),其效果常常能够接近甚至达到手工标注数据的水平。

环境准备与安装

在开始之前,请确保你的 Python 环境已安装 Snorkel。推荐使用 Python 3.8 及以上版本。

pip install snorkel

本教程将使用一个简化的文本分类示例:判断一条电影评论是“正面”还是“负面”情感。我们将创造一组标注函数来为未标注评论投票。

数据准备:未标注文本语料

我们需要一个未标注的数据集。通常,你可以从大量未包含标签的文本开始。这里我们构造一小批示例数据方便演示。

import pandas as pd

# 未标注数据
raw_texts = [
    "这部电影太棒了,演员表演出色,剧情紧凑",
    "浪费时间的烂片,完全不值得看",
    "特效非常震撼,故事情节有点薄弱",
    "我睡着了,无聊至极",
    "导演再次证明了他的才华,经典之作",
    "配乐优美,画面精致,但节奏太慢",
    "不推荐,非常糟糕的体验",
    "整体还行,看得过去"
]

df = pd.DataFrame({"text": raw_texts})
df["label"] = -1  # -1 代表未标注,我们将 ABSTAIN(弃权)时用 -1 表示

核心概念:标注函数 LF

标注函数是 Snorkel 体系的基本单元。每个 LF 接收一个数据点(通常为 cand 类或原始对象),然后返回:

  • 一个类别标签(例如 0 或 1)表示投票给某个类别;
  • 或者 -1(在 Snorkel 中定义常量 ABSTAIN = -1),表示该函数对该样本弃权,不投票。

标注函数可以非常简单且覆盖率低,也可以较复杂但精度高。关键在于组合多个准确率不高、覆盖率各异的 LF,通过标签模型取长补短。

编写标注函数:从规则到外部知识

我们将定义多个基于规则的 LF,并顺带展示如何结合外部知识。首先导入所需模块:

from snorkel.labeling import labeling_function
from snorkel.labeling import PandasLFApplier
from snorkel.labeling import LFAnalysis
import re

# 我们的情感标签:1 表示正面,0 表示负面,-1 表示弃权
POSITIVE = 1
NEGATIVE = 0
ABSTAIN = -1

基于关键词的启发式规则

最简单的弱监督来源就是关键词匹配。

@labeling_function()
def lf_keyword_positive(x):
    """如果出现强烈的正面词汇,标注为正面"""
    positive_words = ["太棒", "经典", "震撼", "优美", "精致", "出色"]
    if any(word in x.text for word in positive_words):
        return POSITIVE
    return ABSTAIN

@labeling_function()
def lf_keyword_negative(x):
    """如果出现极端负面词汇,标注为负面"""
    negative_words = ["烂片", "浪费", "无聊", "糟糕", "不推荐", "睡着"]
    if any(word in x.text for word in negative_words):
        return NEGATIVE
    return ABSTAIN

基于正则表达式的结构规则

某些模式可能无法通过简单关键词覆盖,比如否定句式。

@labeling_function()
def lf_regex_negation(x):
    """如果包含‘不’+正面词的否定结构,可能是负面"""
    # 匹配“不”后面紧跟正面形容词
    pattern = r"不\s*(值得|好看|推荐|错)"
    if re.search(pattern, x.text):
        return NEGATIVE
    return ABSTAIN

混合规则:同时检查多个条件

有时我们希望结合两种启发式来提高精度。

@labeling_function()
def lf_mixed_sentiment(x):
    """如果同时包含正面和负面词,则该条评论情感矛盾,弃权;否则按关键词占比投票"""
    pos = sum(1 for w in ["棒", "好", "出色", "优美"] if w in x.text)
    neg = sum(1 for w in ["烂", "差", "无聊", "糟糕"] if w in x.text)
    # 如果两者同时出现数量相等,说明模糊,弃权
    if pos == 0 and neg == 0:
        return ABSTAIN
    if pos > neg:
        return POSITIVE
    elif neg > pos:
        return NEGATIVE
    else:
        return ABSTAIN

利用外部知识的标注函数

如果我们有一个情感词典或预训练的模型,也可以封装为 LF。这里以极简单的预定义正面/负面词表为例。

# 外部知识:模拟一个小型情感词典
SENT_DICT = {
    "太棒了": POSITIVE,
    "经典": POSITIVE,
    "烂片": NEGATIVE,
    "无聊": NEGATIVE,
    "糟糕": NEGATIVE
}

@labeling_function()
def lf_dict_lookup(x):
    """根据词典查找精确匹配的短语来标注(优先级高)"""
    for phrase, label in SENT_DICT.items():
        if phrase in x.text:
            return label
    return ABSTAIN

你可以轻松将这一模式扩展到任意具备零样本分类能力的 Hugging Face 模型,例如使用 transformerszero-shot-classification pipeline 构建 LF,输出为正面/负面时返回对应标签,置信度过低则弃权。

应用标注函数并分析

我们定义了 5 个标注函数,现在将它们应用到数据集上。

lfs = [
    lf_keyword_positive,
    lf_keyword_negative,
    lf_regex_negation,
    lf_mixed_sentiment,
    lf_dict_lookup
]

applier = PandasLFApplier(lfs=lfs)
L_train = applier.apply(df=df)

L_train 是一个矩阵,行数等于样本数,列数等于 LF 数量,值就是我们定义的标签或 -1。

使用 LFAnalysis 工具查看每个 LF 的覆盖率、冲突率和估计准确率。

analysis = LFAnalysis(L=L_train, lfs=lfs).lf_summary()
print(analysis.head())

输出类似如下(示例):

j Polarity Coverage Overlaps Conflicts
lf_keyword_positive 0 [1] 0.375 0.125 0.125
lf_keyword_negative 1 [0] 0.5 0.125 0.25
...
  • Coverage:该 LF 至少对多少比例的数据给出了非弃权标签。
  • Overlaps:该 LF 与其他 LF 同时给出非弃权标签且标签相同的占比。
  • Conflicts:与其他 LF 标签不一致的占比。

覆盖率与冲突分析帮助判断是否需要增加新的 LF 或调整现有的。冲突很高可能表明某些 LF 噪音过大,需要修改。

训练标签模型:融合弱标注信号

现在用 Snorkel 的 LabelModel 来学习每个 LF 的可靠性,并为每个样本生成一个概率标签。

from snorkel.labeling.model import LabelModel

label_model = LabelModel(cardinality=2, verbose=True)  # 二分类
label_model.fit(L_train=L_train, n_epochs=100, seed=42)

# 输出每个样本为正类的概率
probs_train = label_model.predict_proba(L=L_train)
print(probs_train[:5])

preds_train = label_model.predict(L=L_train) 可以得到硬标签(0 或 1)。

Label Model 背后的思想是:将每个 LF 视为一个准确性未知的“标注器”,利用它们之间的冲突和重叠关系,基于概率图模型学习出每个 LF 的条件依赖性及分类倾向。训练过程不需要任何真实标签。

训练最终判别模型

有了概率标签,我们就可以训练一个任意复杂的监督式模型。Snorkel 提供了方便的工具将这些软标签与原始特征连接。

from snorkel.labeling import filter_unlabeled_dataframe
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.linear_model import LogisticRegression

# 过滤掉没有任何 LF 覆盖的样本(可选)
df_train_filtered, probs_train_filtered = filter_unlabeled_dataframe(
    X=df, y=probs_train, L=L_train
)

# 提取文本特征
vectorizer = CountVectorizer()
X_features = vectorizer.fit_transform(df_train_filtered.text)

# 使用概率标签训练分类器,这里用正类概率作为软标签
positive_probs = probs_train_filtered[:, 1]  # 正类概率
# 二分类可以使用阈值转换硬标签,或直接使用软标签训练,这里简单使用阈值
y_hard = (positive_probs >= 0.5).astype(int)

final_model = LogisticRegression()
final_model.fit(X_features, y_hard)

# 测试新数据
new_texts = ["剧情很有趣,推荐大家去看", "实在太难看了"]
X_new = vectorizer.transform(new_texts)
preds = final_model.predict(X_new)
print(preds)  # 期望输出 [1, 0]

完整流程总结与最佳实践

  1. 定义标注函数:结合领域知识编写 20-50 个甚至更多的 LF,每个 LF 不必完美,但要相互补充。资源允许时,加入基于预训练模型的 LF 可极大提升覆盖率和准确度。
  2. 应用并分析 LF:用 PandasLFApplier 得到标签矩阵,利用 LFAnalysis 监控覆盖、冲突、极性的分布。删除那些只会引起强烈冲突且覆盖率极低的 LF。
  3. 训练标签模型:使用 LabelModel 自动融合弱标签,得到高质量概率标签。可设置部分验证集(如有少量标注)来评估标签模型的准确度。
  4. 过滤与训练最终模型:移除无任何 LF 覆盖的样本(也可保留但效果可能微弱),用概率标签训练下游分类器。可选的策略:将标签模型的概率直接作为目标变量,或者使用置信度阈值筛选数据。
  5. 迭代闭环:分析最终模型在验证集上的错误类型,反哺 LF 的设计。增加新规则、修正旧规则,形成迭代式的弱监督开发循环。

Snorkel 的强大之处在于它将人的领域知识以程序化方式注入,同时凭借统计建模消除了人为规则的主观偏差。当你面临标注数据匮乏的困境时,不妨尝试用 Snorkel 搭建一套弱监督标签系统,它极可能大幅缩短你的模型开发周期,同时保持令人满意的预测性能。