Python弱监督学习神器:Snorkel 从入门到实战全攻略

一、Snorkel 库核心概述

1.1 用途与工作原理

Snorkel 是一款专为弱监督学习(Weakly Supervised Learning)设计的 Python 库,核心解决机器学习中标注数据稀缺、标注成本高昂的痛点。它允许开发者通过编写简单的标注函数(Labeling Functions, LFs)、集成多个弱监督信号,无需人工标注大量数据,就能快速生成高质量的训练标签,进而训练出性能优异的机器学习模型。

其工作原理可概括为三步:首先,用户针对任务编写多个标注函数,每个函数基于不同的启发式规则、外部知识库或弱监督信号对数据进行标注;其次,Snorkel 的标签模型(Label Model)会自动学习这些标注函数的可靠性权重,解决函数间的冲突与冗余,输出概率化的训练标签;最后,用生成的标签训练下游任务模型(如分类器),完成端到端的弱监督学习流程。

1.2 优缺点分析

优点

  1. 大幅降低标注成本:无需人工标注数千上万条数据,仅需编写少量标注函数即可生成训练标签,效率提升显著。
  2. 灵活性强:支持文本分类、实体识别、图像分类等多种任务,标注函数可灵活结合规则、正则表达式、外部模型等多种信号。
  3. 标签质量可控:标签模型通过学习标注函数的可靠性,有效过滤噪声标签,生成的标签质量优于单一规则标注。
  4. 与主流框架兼容:可无缝对接 Scikit-learn、TensorFlow、PyTorch 等主流机器学习/深度学习框架,适配现有工作流。

缺点

  1. 有一定学习门槛:需要用户理解弱监督学习的核心思想,掌握标注函数的编写逻辑,对新手不够友好。
  2. 标注函数编写依赖领域知识:针对特定任务的标注函数需要结合领域经验,否则可能导致标签质量下降。
  3. 性能受限于标注函数质量:若标注函数设计不合理、覆盖场景不全,最终模型性能会大打折扣。

1.3 License 类型

Snorkel 采用 Apache License 2.0 开源协议,该协议允许用户自由使用、修改、分发源代码,可用于商业项目,仅需保留原作者版权声明和协议文本。

二、Snorkel 安装与环境配置

2.1 安装方式

Snorkel 支持多种安装方式,推荐使用 pip 进行快速安装,同时需确保 Python 版本在 3.7~3.10 之间(版本过高可能存在兼容性问题)。

方法1:PyPI 官方安装

打开命令行终端,执行以下命令:

pip install snorkel

方法2:源码编译安装

若需要使用最新开发版本,可从 GitHub 克隆源码并安装:

# 克隆仓库
git clone https://github.com/snorkel-team/snorkel.git
# 进入仓库目录
cd snorkel
# 安装依赖并编译
pip install -r requirements.txt
pip install -e .

2.2 环境验证

安装完成后,可通过以下 Python 代码验证是否安装成功:

import snorkel
# 打印 Snorkel 版本号
print(f"Snorkel 版本:{snorkel.__version__}")
# 验证核心模块是否可用
from snorkel.labeling import LabelingFunction, LFApplier
print("核心模块导入成功!")

若运行无报错且输出版本号,则说明环境配置完成。

三、Snorkel 核心概念与基础用法

3.1 核心概念解析

在使用 Snorkel 前,需先理解以下几个核心概念,这是构建弱监督学习流程的基础:

  1. 标注函数(Labeling Function, LF):弱监督学习的核心,是用户编写的、基于启发式规则对数据进行标注的函数。每个 LF 可以对数据样本标注正类(1)、负类(0)、弃权(-1)三种标签之一,弃权表示该函数无法判断该样本的类别。
  2. 标签模型(Label Model):Snorkel 的核心组件,用于自动学习多个 LF 的可靠性权重,解决 LF 之间的冲突(如一个 LF 标正类,另一个标负类)和冗余,最终输出每个样本的概率化标签。
  3. 标签应用器(LFApplier):用于将所有标注函数应用到数据集上,生成一个标签矩阵(Label Matrix),矩阵的每一行对应一个样本,每一列对应一个 LF 的标注结果。
  4. 下游任务模型(Downstream Model):使用标签模型生成的标签进行训练的模型,如文本分类器、实体识别器等,可根据任务需求选择传统机器学习模型或深度学习模型。

3.2 基础工作流程

Snorkel 的典型工作流程分为四步:编写标注函数 → 生成标签矩阵 → 训练标签模型 → 训练下游模型。下面以文本情感分类任务为例,详细演示每一步的实现方法。

四、实战:基于 Snorkel 的文本情感分类

本次实战任务为电影评论情感分类,目标是将评论分为正面(1)负面(0)两类。我们将使用 Snorkel 编写标注函数,无需人工标注数据,直接生成训练标签并训练分类器。

4.1 数据集准备

我们使用 Snorkel 内置的小型电影评论数据集,也可替换为自定义数据集。首先导入所需模块并加载数据:

import pandas as pd
from snorkel.datasets import load_movie_reviews

# 加载数据集
train_df, test_df = load_movie_reviews()
# 查看数据集结构
print("训练集样本数:", len(train_df))
print("测试集样本数:", len(test_df))
# 查看前5条训练数据
print(train_df[["text", "sentiment"]].head())

数据集的 text 列是电影评论文本,sentiment 列是真实情感标签(1 为正面,0 为负面),在弱监督学习中,我们不会使用真实标签,仅用于最终测试模型性能。

4.2 编写标注函数

标注函数是弱监督学习的核心,我们需要结合情感分类的任务特点,编写多个基于关键词、正则表达式的 LF。首先定义标签常量:

# 定义标签常量
ABSTAIN = -1
POSITIVE = 1
NEGATIVE = 0

接下来编写 5 个不同的标注函数,分别基于正面关键词、负面关键词、情感强度词、否定词、长度规则进行标注:

标注函数1:基于正面关键词标注

该函数判断评论中是否包含正面关键词(如 “great”、”excellent”、”amazing”),若包含则标注为正面(1),否则弃权(-1)。

from snorkel.labeling import LabelingFunction

# 定义正面关键词列表
positive_keywords = ["great", "excellent", "amazing", "fantastic", "wonderful", "perfect"]

@LabelingFunction()
def lf_positive_keywords(x):
    """基于正面关键词的标注函数"""
    return POSITIVE if any(word in x.text.lower() for word in positive_keywords) else ABSTAIN

标注函数2:基于负面关键词标注

该函数判断评论中是否包含负面关键词(如 “bad”、”terrible”、”awful”),若包含则标注为负面(0),否则弃权(-1)。

# 定义负面关键词列表
negative_keywords = ["bad", "terrible", "awful", "horrible", "disappointing", "worst"]

@LabelingFunction()
def lf_negative_keywords(x):
    """基于负面关键词的标注函数"""
    return NEGATIVE if any(word in x.text.lower() for word in negative_keywords) else ABSTAIN

标注函数3:基于情感强度词标注

该函数判断评论中是否包含强情感词(如 “love”、”hate”),”love” 对应正面,”hate” 对应负面。

@LabelingFunction()
def lf_sentiment_intensity(x):
    """基于情感强度词的标注函数"""
    text = x.text.lower()
    if "love" in text:
        return POSITIVE
    elif "hate" in text:
        return NEGATIVE
    else:
        return ABSTAIN

标注函数4:基于否定词的标注函数

该函数处理包含否定词的情况,如 “not great” 应标注为负面,”not bad” 应标注为正面。

import re

@LabelingFunction()
def lf_negative_expressions(x):
    """基于否定词的标注函数"""
    text = x.text.lower()
    # 匹配 "not + 正面词" 结构
    if re.search(r"not (great|excellent|amazing|good)", text):
        return NEGATIVE
    # 匹配 "not + 负面词" 结构
    elif re.search(r"not (bad|terrible|awful)", text):
        return POSITIVE
    else:
        return ABSTAIN

标注函数5:基于评论长度的标注函数

通常,正面评论可能更长(用户愿意详细分享体验),负面评论可能更短。该函数设定评论长度阈值,长评论标注为正面,短评论标注为负面。

@LabelingFunction()
def lf_review_length(x):
    """基于评论长度的标注函数"""
    # 计算单词数量
    word_count = len(x.text.split())
    if word_count > 50:
        return POSITIVE
    elif word_count < 10:
        return NEGATIVE
    else:
        return ABSTAIN

4.3 生成标签矩阵

编写完标注函数后,需要使用 LFApplier 将这些函数应用到训练数据集上,生成标签矩阵。标签矩阵的形状为 (样本数, 标注函数数),每个元素是对应 LF 对该样本的标注结果。

from snorkel.labeling import LFApplier

# 收集所有标注函数
lfs = [lf_positive_keywords, lf_negative_keywords, lf_sentiment_intensity, lf_negative_expressions, lf_review_length]

# 创建标签应用器
applier = LFApplier(lfs=lfs)

# 应用标注函数到训练集,生成标签矩阵
L_train = applier.apply(df=train_df)

# 查看标签矩阵形状
print("标签矩阵形状:", L_train.shape)
# 查看前5个样本的标注结果
print("前5个样本的标注结果:")
print(L_train[:5])

输出的标签矩阵中,-1 表示弃权,0 表示负面,1 表示正面。例如 [1, -1, 1, -1, 0] 表示第一个 LF 标正面,第二个弃权,第三个标正面,第四个弃权,第五个标负面。

4.4 分析标注函数性能

在训练标签模型前,可通过 Snorkel 提供的工具分析标注函数的性能,包括覆盖率(Coverage)冲突率(Conflict Rate)重叠率(Overlap Rate)

  • 覆盖率:标注函数对多少样本进行了标注(非弃权),覆盖率越高,函数的作用越大。
  • 重叠率:两个标注函数同时对同一个样本标注的比例,重叠率过高可能表示函数冗余。
  • 冲突率:两个标注函数对同一个样本标注不同标签的比例,冲突率过高需要优化标注函数。
from snorkel.labeling import analysis

# 计算标注函数的统计指标
lf_stats = analysis.LFAnalysis(L_train, lfs).lf_stats()
print(lf_stats)

输出结果会展示每个 LF 的覆盖率、重叠率和冲突率,帮助我们筛选和优化标注函数。例如,若某个 LF 的覆盖率极低(如低于 5%),可以考虑删除或修改该函数。

4.5 训练标签模型

标签模型是 Snorkel 的核心,它无需真实标签,仅通过标签矩阵就能学习每个标注函数的可靠性权重,并输出概率化的训练标签。我们使用 LabelModel 类来训练标签模型:

from snorkel.labeling import LabelModel

# 初始化标签模型,设置类别数为2(正面/负面)
label_model = LabelModel(cardinality=2, verbose=True)

# 训练标签模型
label_model.fit(L_train=L_train, n_epochs=500, lr=0.001, log_freq=100)

# 对训练集生成概率化标签
Y_train_probs = label_model.predict_proba(L=L_train)
# 生成硬标签(概率大于0.5为正面,否则为负面)
Y_train_pred = label_model.predict(L=L_train)

# 查看生成的标签形状
print("概率化标签形状:", Y_train_probs.shape)
print("硬标签形状:", Y_train_pred.shape)
# 查看前5个样本的概率化标签和硬标签
print("前5个样本的概率化标签:", Y_train_probs[:5])
print("前5个样本的硬标签:", Y_train_pred[:5])

概率化标签是一个二维数组,每一行对应一个样本,每一列对应一个类别的概率(如 [0.1, 0.9] 表示该样本为正面的概率是 0.9)。硬标签是基于概率的二分类结果,取值为 0 或 1。

4.6 训练下游分类模型

生成训练标签后,我们可以使用这些标签训练下游分类模型。本次实战使用 Scikit-learn 的逻辑回归模型作为下游模型,特征提取使用 TF-IDF 向量化器

步骤1:特征提取

将文本数据转换为 TF-IDF 特征向量:

from sklearn.feature_extraction.text import TfidfVectorizer

# 初始化 TF-IDF 向量化器
vectorizer = TfidfVectorizer(stop_words="english", max_features=10000)

# 对训练集和测试集文本进行特征提取
X_train = vectorizer.fit_transform(train_df["text"])
X_test = vectorizer.transform(test_df["text"])

# 提取测试集真实标签(仅用于评估)
Y_test = test_df["sentiment"].values

print("训练集特征形状:", X_train.shape)
print("测试集特征形状:", X_test.shape)

步骤2:训练下游模型

使用标签模型生成的硬标签训练逻辑回归模型:

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report

# 初始化逻辑回归模型
downstream_model = LogisticRegression(max_iter=1000)

# 使用弱监督标签训练模型
downstream_model.fit(X_train, Y_train_pred)

# 对测试集进行预测
Y_test_pred = downstream_model.predict(X_test)

# 评估模型性能
print(f"测试集准确率:{accuracy_score(Y_test, Y_test_pred):.4f}")
print("\n分类报告:")
print(classification_report(Y_test, Y_test_pred, target_names=["负面", "正面"]))

输出的分类报告将包含准确率、精确率、召回率和 F1 分数等指标。在实际应用中,通过优化标注函数,模型性能可进一步提升。

步骤3:使用概率化标签训练模型(进阶)

除了硬标签,Snorkel 还支持使用概率化标签训练下游模型,这种方式可以保留标签的不确定性,通常能获得更好的性能。对于 Scikit-learn 模型,可通过 class_weight 参数实现;对于深度学习模型,可直接使用概率化标签作为损失函数的输入。

# 使用概率化标签调整类别权重
import numpy as np

# 计算每个样本的权重(概率的绝对值)
sample_weight = np.max(Y_train_probs, axis=1)

# 训练模型时加入样本权重
downstream_model_weighted = LogisticRegression(max_iter=1000)
downstream_model_weighted.fit(X_train, Y_train_pred, sample_weight=sample_weight)

# 评估加权模型性能
Y_test_pred_weighted = downstream_model_weighted.predict(X_test)
print(f"加权模型测试集准确率:{accuracy_score(Y_test, Y_test_pred_weighted):.4f}")

五、进阶应用:实体识别任务

除了文本分类,Snorkel 还广泛应用于命名实体识别(NER)任务。NER 任务需要识别文本中的实体(如人名、地名、机构名),传统方法需要大量人工标注的序列数据,而 Snorkel 可通过编写序列标注函数,快速生成训练标签。

5.1 序列标注函数编写

在 NER 任务中,标注函数的输入是句子中的每个 token(词),输出是该 token 的实体标签(如 PER 表示人名,LOC 表示地名,O 表示非实体)。以下是一个简单的 NER 标注函数示例:

from snorkel.labeling import labeling_function
from snorkel.types import Token

# 定义实体标签常量
PER = 1  # 人名
LOC = 2  # 地名
O = 0    # 非实体

@labeling_function()
def lf_person_names(x: Token) -> int:
    """识别人名的标注函数,基于常见姓氏列表"""
    common_last_names = ["Smith", "Johnson", "Williams", "Brown", "Jones"]
    # 判断 token 是否为大写开头且在姓氏列表中
    if x.text.istitle() and x.text in common_last_names:
        return PER
    return O

@labeling_function()
def lf_location_names(x: Token) -> int:
    """识别地名的标注函数,基于常见地名列表"""
    common_locations = ["New York", "London", "Paris", "Tokyo", "Beijing"]
    # 判断 token 是否为地名的一部分
    if any(location in x.text for location in common_locations):
        return LOC
    return O

5.2 序列标签模型训练

对于序列标注任务,Snorkel 提供了 SequenceLabelModel 类,专门用于处理序列数据的标签生成。其使用流程与文本分类类似,只需将标签应用器替换为 SequenceLFApplier

from snorkel.labeling import SequenceLFApplier, SequenceLabelModel

# 收集序列标注函数
sequence_lfs = [lf_person_names, lf_location_names]

# 创建序列标签应用器
sequence_applier = SequenceLFApplier(lfs=sequence_lfs)

# 应用标注函数到序列数据集,生成序列标签矩阵
L_sequence = sequence_applier.apply(df=sequence_train_df)

# 初始化序列标签模型
sequence_label_model = SequenceLabelModel(cardinality=3, verbose=True)

# 训练模型
sequence_label_model.fit(L_sequence, n_epochs=100, lr=0.01)

# 生成序列标签
Y_sequence_probs = sequence_label_model.predict_proba(L_sequence)

六、Snorkel 与深度学习框架的集成

Snorkel 可无缝对接 TensorFlow、PyTorch 等深度学习框架,用弱监督标签训练深度模型。以下是与 PyTorch 集成的示例,训练一个基于 LSTM 的文本分类模型:

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader

# 定义自定义数据集类
class TextDataset(Dataset):
    def __init__(self, X, Y_probs):
        self.X = torch.tensor(X.toarray(), dtype=torch.float32)
        self.Y_probs = torch.tensor(Y_probs, dtype=torch.float32)

    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        return self.X[idx], self.Y_probs[idx]

# 定义 LSTM 分类模型
class LSTMClassifier(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.lstm = nn.LSTM(input_dim, hidden_dim, batch_first=True)
        self.fc = nn.Linear(hidden_dim, output_dim)
        self.softmax = nn.Softmax(dim=1)

    def forward(self, x):
        # 调整输入形状为 (batch_size, seq_len, input_dim)
        x = x.unsqueeze(1)
        lstm_out, _ = self.lstm(x)
        # 取最后一个时间步的输出
        last_out = lstm_out[:, -1, :]
        out = self.fc(last_out)
        return self.softmax(out)

# 准备数据加载器
train_dataset = TextDataset(X_train, Y_train_probs)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)

# 初始化模型、损失函数和优化器
input_dim = X_train.shape[1]
hidden_dim = 128
output_dim = 2

model = LSTMClassifier(input_dim, hidden_dim, output_dim)
criterion = nn.BCELoss()  # 使用二元交叉熵损失
optimizer = optim.Adam(model.parameters(), lr=0.001)

# 训练模型
num_epochs = 10
for epoch in range(num_epochs):
    model.train()
    total_loss = 0.0
    for batch_x, batch_y in train_loader:
        optimizer.zero_grad()
        outputs = model(batch_x)
        loss = criterion(outputs, batch_y)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Epoch {epoch+1}/{num_epochs}, Loss: {total_loss/len(train_loader):.4f}")

# 评估模型
model.eval()
with torch.no_grad():
    X_test_tensor = torch.tensor(X_test.toarray(), dtype=torch.float32)
    Y_test_probs = model(X_test_tensor)
    Y_test_pred = torch.argmax(Y_test_probs, dim=1).numpy()
    print(f"LSTM 模型测试集准确率:{accuracy_score(Y_test, Y_test_pred):.4f}")

七、相关资源链接

  • PyPI 地址:https://pypi.org/project/snorkel
  • Github 地址:https://github.com/snorkel-team/snorkel
  • 官方文档地址:https://snorkel.readthedocs.io/en/latest/

关注我,每天分享一个实用的Python自动化工具。