ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

半监督学习Co-training协同训练:从论文到代码的完整实践

半监督学习Co-training协同训练:从论文到代码的完整实践 机器学习教程 之 半监督学习 Co-training 协同训练论文、算法、数据集、代码做机器学习这几年我遇到过不少“标注数据不够”的尴尬局面。公司给的任务很明确——做一个文本分类模型但标注同学只腾得出三天时间最终拿到手的有标签样本才几百条没标签的原始文本倒是有好几万条躺在数据库里。扔掉这些没标签的数据吧觉得可惜硬着头皮只用几百条去训练吧模型泛化能力又差得离谱。当时我翻了很多半监督学习的资料最后真正落地解决了问题的就是这篇文章要讲的 Co-training协同训练。它的思路并不复杂既然一个特征视图不够强那就用两个相互独立的特征视图去训练两个模型让它们在有标签数据上学到的“观点”互相补充再借助未标注数据不断迭代。整个过程有点像公司里两个不同背景的同事结对工作各自的盲区恰恰能被对方的优势覆盖掉。这篇教程适合谁如果你是做分类任务、手上正卡在“标注数据严重不足”这个环节的机器学习工程师或者你是正在啃半监督学习方向的论文、想找一个真正能跑通的 baseline 来对比效果的在校学生这篇文章都能帮上忙。我会从论文源头、算法流程、数据集构造、代码实现到调参心得完整拆解 Co-training 的每个关键环节并给出可以直接复用的 Python 代码。1. Co-training 的核心思想为什么它值得你花时间1.1 从“标注数据太贵”这个痛点说起监督学习的基本假设是你有足够多、足够准确的标注样本。但现实中的情况往往相反——标注成本高、周期长而且某些领域的标注还需要专家参与比如医疗影像、法律文书、金融风控。反过来原始数据可能每天都在大量产生不愁没数据只愁没标注。半监督学习Semi-Supervised Learning就是在这个背景下成长起来的它试图利用大量未标注数据来辅助少量有标签数据的训练从而降低人工标注成本。半监督学习的方法有很多条技术路线自训练Self-training、图标签传播Label Propagation、一致性正则化Consistency Regularization等。Co-training 属于自训练思想的一个特殊分支但它和普通自训练有一个本质区别——它不靠一个模型“自说自话”而是靠两个模型“互相补充”。这个区别在后面的代码实验里会看得非常明显。1.2 两个视图的假设Co-training 的核心逻辑拆解Co-training 是由 Avrim Blum 和 Tom Mitchell 在 1998 年提出的原论文标题是 Combining labeled and unlabeled data with co-training。它的核心假设只有一个数据的特征可以自然划分为两个条件独立的视图view。这个“视图”怎么理解我常用一个生活化类比来解释。假设你现在要判断一篇学术论文属于哪个研究领域你可以看两个完全不同的信息源一个是论文正文的文字表述另一个是参考文献的引用关系。这两个信息源各自独立但都包含足够判断论文方向的线索。哪怕你在正文标注样本很少只要引用关系的标注稍微全一点两个模型一结合整体判断力就能大幅提升。这是 Co-training 工作流程的最简表达把特征分成两个视图比如文本内容特征、网络结构特征。在有标签数据集上分别训练两个分类器比如一个朴素贝叶斯一个决策树。两个分类器分别对未标注数据进行预测各自挑出置信度最高的若干条样本。把双方挑中的样本连同各自的预测标签加入到对方的训练集中。重复步骤 3-4直到达到迭代次数或分类器在验证集上收敛。这里的“视图独立性”是整个流程的发动机。如果两个视图本质上高度相关例如一个视图像是另一个视图的线性变换那么 Co-training 的效果会打折甚至退化成普通自训练。1.3 和自训练、标签传播到底有什么区别很多初学者会把 Co-training 和 Self-training 混为一谈因为它们都涉及利用模型自己的预测结果去扩充训练集。但仔细对照一下差异其实很明显维度Self-trainingCo-training标签传播分类器数量单个两个图模型特征视图全特征两个条件独立视图全特征伪标签来源模型自身的自信预测另一视图模型的自信预测图结构上的邻居传播适用场景通用分类任务特征天然可分两类的任务样本间存在图关系我自己实验下来的体感是Self-training 容易放大初始错误一旦模型在早期把错误标签当成“自信预测”加进去错误就滚雪球般增大。Co-training 通过两个模型互相把关其中一侧的错误不容易主导整体方向稳定程度明显更好。2. 从论文到算法核心原理与演进脉络2.1 原始论文的出发点与数学直觉1998 年这篇论文发表的时代背景是网页分类研究非常火热Blum 和 Mitchell 当时拿来做实验的也是网页分类任务。网页分类有一个得天独厚的条件每个网页既可以用页面本身的词频向量来描述也可以用指向该网页的其他页面的锚文本anchor text向量来描述。这两个视图是完全不同的信息通道却都指向同一个分类目标。论文给出了一个很有意思的理论结果只要两个视图满足条件独立性——即在标签给定的情况下两个视图的分布相互独立——并且每个视图都包含足够的信息训练出弱学习器那么利用未标注数据的迭代训练可以把分类器的泛化误差降到任意小。用大白话说就是只要有足够多的未标注数据初始弱分类器可以逐步增强到任意逼近最优分类器的程度。这个结论给半监督学习的价值提供了比较坚实的理论支持。我在实际工程里并不太去纠结理论界但有两点启发性我一直保留至今。一是“弱学习器也可以迭代变强”这种设定非常适合现实场景因为手头没有强模型时多个弱视角的组合依然可以走得很远。二是未标注数据的价值是有前提的前提不满足时收益会大打折扣这个我在第 5 章会展开聊。2.2 算法步骤的伪码逻辑与参数说明我们先把 Co-training 的流程整理成可以直接读懂的伪码输入: L 有标签数据集 U 未标注数据集 x1 视图1的特征 x2 视图2的特征 k 每轮每个视图挑选的样本数 T 最大迭代轮数 p 每轮挑选样本时的置信度阈值 过程: 在 L 上训练分类器 f1 (只用 x1 特征) 在 L 上训练分类器 f2 (只用 x2 特征) for t 1 to T: 使用 f1 对 U 中的样本预测得到每个样本属于正类的置信度 挑选置信度最高的 k 个样本记为 S1 使用 f2 对 U 中的样本预测得到每个样本属于负类的置信度 挑选置信度最高的 k 个样本记为 S2 将 S1 和 S2 从 U 中移除 用 S1 及其预测标签扩充 L重新训练 f2 用 S2 及其预测标签扩充 L重新训练 f1 end for 输出: 训练完成的 f1 和 f2这里有三个超参数比较关键k 表示每一轮新增样本量T 控制总迭代次数p 控制置信度门槛。实践中k 一般设为 1 到 50 之间的值NLP 任务里我常用 10~30T 通常在 20 到 100 之间早停机制必须加上否则后期不断加入低质量伪标签会拉低效果p 建议设为 0.7 到 0.95 之间太低了会把噪声引入训练集太高了迭代提升速度会非常慢。2.3 从 Co-training 到 Tri-training算法演进Co-training 对“双视图”的依赖在实际项目中常常是个卡点——并不是所有任务都能天然切出两个独立视图。后来周志华等人提出的 Tri-training 就绕开了这个限制它用三个在原始特征上训练出的分类器通过对未标注样本进行“少数服从多数”投票只有多个分类器意见一致时才把该样本加入训练集。这样就不需要显式切分特征视图适用范围广了很多。我个人的经验是如果你能切分出两个相对独立的信息通道Co-training 的收益更直接如果做不了视图切分可以考虑改用 Tri-training 的思想用“多分类器投票 伪标签置信度”的组合方式来近似模拟 Co-training 的效果。在后面的实际操作部分我会给出一个折中方案即使原始特征不适合切分你也可以用不同的特征子集来构造视图等于在手头数据上“人工创建”独立性。3. 数据集选择与预处理让 Co-training 真正发挥威力的前提3.1 什么样的数据集适合 Co-training不是所有数据集都适合直接套 Co-training。我总结了三个硬性条件第一特征维度和样本量都要够大。如果特征维度不到几十维硬拆成两个视图后每个视图的信息容量可能严重不足初始分类器就没法“站稳脚跟”。第二特征能够按照某种逻辑切分。例如文本分类任务可以切分为“标题词特征 正文字词特征”或者“词频特征 词向量特征”图像相关任务可以从“颜色纹理特征 形状边缘特征”等角度切分。有些任务天然具备这种切分条件例如网页分类中的页面内容和入链锚文本论文分类中的摘要内容和引用关系。第三未标注数据和已标注数据要来自同一个分布。这个条件适用于所有半监督方法但 Co-training 尤其敏感因为迭代过程中伪标签会成为新一轮学习的输入分布漂移会被逐步放大。在真实项目中我遇到过一个反例某电商平台的商品品类预测任务当时我把“商品标题文本”和“商品图片特征”作为两个视图结果发现 Co-training 的效果还不如直接用有标签数据训练的 baseline。后来排查原因发现是由于图片特征的模型本身太弱它提供的伪标签噪声比例过高不仅没给文本模型提供帮助反而把文本模型的决策边界带偏了。这个教训让我意识到两个视图的初始能力不能悬殊太远否则强的那个会被弱的那个拖累。3.2 公开数据集推荐与获取方式如果你想跑通 Co-training 的实验我推荐几个网上容易获取的数据集20 Newsgroups经典文本分类数据集约 2 万篇新闻文档20 个类别。你可以把“邮件头特征”From、Subject 等和“正文词频特征”当作两个视图切分非常自然很适合入门。WebKB 或类似网页分类数据集网页数据集天然适合用“页面内容”和“锚文本/链接关系”作为双视图和原论文场景最接近。UCI 上的 Adult 收入预测特征包含数值和类别你可以按特征类型切分成“个人信息特征”和“职业相关特征”两个视图操作简单方便快速验证代码逻辑。自己造一份数据做验证如果只是想调试代码流程可以用 sklearn 的 make_classification 生成一份二分类数据然后把前一半特征当作视图1、后一半特征当作视图2。这种方法的好处是你可以精确控制两个视图的信噪比非常便于理解 Co-training 的行为。从 20 Newsgroups 这类公开数据集出发的好处是你可以更专注于算法本身的实现和调参而不是在数据清洗上浪费大量时间。3.3 数据集切分与预处理构建“双视图”的实操细节实际操作中把一份数据切成两个视图并不只是“前一半特征给视图1后一半给视图2”那么简单。关键要看特征的语义是否闭合——即单个视图的信息是不是足够独立地支撑分类任务。以 20 Newsgroups 为例一个合理的切分方法是视图1以主题、关键词等信息构成的文档向量。视图2以正文内容构成的 TF-IDF 向量。代码上可以这样构造import numpy as np from sklearn.datasets import fetch_20newsgroups from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split # 加载数据这里只挑两个类别方便演示 cats [comp.graphics, sci.space] news fetch_20newsgroups(subsetall, categoriescats, shuffleTrue, random_state42) # 模拟视图1从文本中切出前30%字符作为“标题摘要” # 模拟视图2从文本中切出后70%字符作为“正文” view1_texts [doc[:int(len(doc) * 0.3)] for doc in news.data] view2_texts [doc[int(len(doc) * 0.3):] for doc in news.data] vectorizer_title TfidfVectorizer(max_features2000, stop_wordsenglish) vectorizer_body TfidfVectorizer(max_features5000, stop_wordsenglish) X1 vectorizer_title.fit_transform(view1_texts) X2 vectorizer_body.fit_transform(view2_texts) y news.target # 划分有标签集和未标注集这里模拟只有20%的样本有标签 X1_lab, X1_unlab, X2_lab, X2_unlab, y_lab, y_unlab train_test_split( X1, X2, y, test_size0.8, stratifyy, random_state42 )这种按文本段落物理切割的方式虽然有模拟成分但在特征语义上确实构造了两个差异化的信息源。真实项目中如果能把“内容”和“元信息”拆开效果会更好。一个通用的预处理建议是构建视图前先单独验证每个视图在有标签数据上的单独分类能力如果某个视图的单独准确率明显低于随机水平或者低于另一个视图很多那就需要重新设计视图切分方式了。4. 代码实现从零构建一个可运行的 Co-training 流程4.1 环境准备与工具选择我建议直接用 Python 3.8 环境主要依赖 sklearn、numpy。为了可视化收敛曲线可以选择性安装 matplotlib。下面的代码在 Jupyter Notebook 或普通 Python 脚本里都可以直接运行。pip install scikit-learn numpy matplotlib模型层面原论文用的是朴素贝叶斯分类器实际使用中 LR 或者线性 SVM 也能取得不错的效果。从我自己的实验经验来看sklearn 中的 LogisticRegression 在多轮迭代中表现非常稳定。朴素贝叶斯在特征独立性假设更强时会受到视图切分方式的影响。4.2 核心训练循环代码与逐行解释下面的代码是我整理过的一个精简但完整的 Co-training 实现它接收两个视图的训练矩阵循环执行“伪标签挑选 扩充训练集 重训模型”的操作import numpy as np from sklearn.base import clone from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score def co_training( X1_lab, X2_lab, y_lab, X1_unlab, X2_unlab, model1, model2, k10, max_iter50, conf_threshold0.8, eval_fnNone, ): Co-training 主循环。 参数 ---------- X1_lab, X2_lab: 有标签数据在两个视图上的特征矩阵 y_lab: 有标签数据的标签 X1_unlab, X2_unlab: 未标注数据在两个视图上的特征矩阵 model1, model2: 两个分类器实例 k: 每轮每个视图挑选的样本数 max_iter: 最大迭代轮数 conf_threshold: 置信度阈值 eval_fn: 可选验证集评估函数 history [] # 复制数组避免修改外部引用 X1_unlab X1_unlab.copy() X2_unlab X2_unlab.copy() # 准备已标注、未标注索引 labels y_lab.copy() # 将模型克隆出来避免在原始模型上修改 clf1 clone(model1) clf2 clone(model2) for iteration in range(max_iter): # 在初始/扩充后的有标签数据上训练 clf1.fit(X1_lab, labels) clf2.fit(X2_lab, labels) # 如果传入验证函数每次迭代都记录验证指标 if eval_fn is not None: val_acc eval_fn(clf1, clf2) history.append(val_acc) # 视图1模型挑选高置信度正类样本 prob1 clf1.predict_proba(X1_unlab)[:, 1] pos_idx np.argsort(prob1)[::-1][:k] # 视图2模型挑选高置信度负类样本 prob2 clf2.predict_proba(X2_unlab)[:, 1] neg_idx np.argsort(prob2)[:k] # 过滤出置信度超过阈值的样本 pos_idx pos_idx[prob1[pos_idx] conf_threshold] neg_idx neg_idx[prob2[neg_idx] conf_threshold] # 如果挑不出任何符合条件的样本提前终止 if len(pos_idx) 0 and len(neg_idx) 0: print(fIteration {iteration}: no sample selected, early stop.) break # 将选中的样本从未标注集中移除并加入训练集 selected_idx np.concatenate([pos_idx, neg_idx]) if len(selected_idx) 0: break # 新增样本的伪标签 pseudo_labels np.array([1] * len(pos_idx) [0] * len(neg_idx)) # 扩充训练特征 X1_lab np.vstack([X1_lab, X1_unlab[selected_idx]]) X2_lab np.vstack([X2_lab, X2_unlab[selected_idx]]) labels np.concatenate([labels, pseudo_labels]) # 从未标注集中删除已选区样本 X1_unlab np.delete(X1_unlab, selected_idx, axis0) X2_unlab np.delete(X2_unlab, selected_idx, axis0) if iteration % 10 0: print(fIteration {iteration}, training size: {len(labels)}) return clf1, clf2, history代码逻辑看起来不复杂但有几个细节值得专门说明。第一行中我把 unlab 数组做了 copy是为了避免在迭代过程中直接修改外部传入的对象这一点在真实项目中尤其重要因为你会反复调整参数做实验如果原始数据被污染了后续对比实验就完全不可信了。另一个细节是选样策略我让视图1挑选正类样本视图2挑选负类样本这是原论文非常核心的思想——两个模型各自补充对方“不太容易发现的样本类型”。如果只做正负类统一挑选效果会明显下降。4.3 在合成数据上验证代码正确性拿到代码后第一步我建议先在合成数据上跑通验证逻辑正确然后再迁移到真实数据集。下面是用 sklearn 生成双视图数据的示例from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.naive_bayes import GaussianNB # 生成数据这里直接生成两个视图的原始特征再把它们拼接在一起 X_view1, y1 make_classification( n_samples2000, n_features20, n_informative10, n_redundant5, n_clusters_per_class1, random_state42 ) X_view2, y2 make_classification( n_samples2000, n_features15, n_informative8, n_redundant3, n_clusters_per_class1, random_state42 ) # 保证两个视图的样本对应相同的标签 assert len(y1) len(y2) 2000 # 模拟只有 10% 的样本有标签 n_labeled 200 X1_lab, X1_unlab, X2_lab, X2_unlab, y_lab, y_unlab train_test_split( X_view1, X_view2, y1, test_size0.9, stratifyy1, random_state42 ) # 验证集单独留出来 X1_tr, X1_te, X2_tr, X2_te, y_tr, y_te train_test_split( X_view1, X_view2, y1, test_size0.3, stratifyy1, random_state1 ) def evaluate(clf1, clf2): pred1 clf1.predict(X1_te) pred2 clf2.predict(X2_te) avg_acc (accuracy_score(y_te, pred1) accuracy_score(y_te, pred2)) / 2 return avg_acc model1 GaussianNB() model2 GaussianNB() clf1, clf2, history co_training( X1_lab, X2_lab, y_lab, X1_unlab, X2_unlab, model1, model2, k10, max_iter80, conf_threshold0.8, eval_fnevaluate, ) print(Final validation accuracy:, evaluate(clf1, clf2))运行后观察 history 列表如果迭代过程中验证准确率逐渐爬升并趋于平稳说明 Co-training 在正常发挥作用。如果曲线在某个节点掉头直下通常说明伪标签噪声已经失控这时需要调低 k、提高置信度阈值或者在验证集上做早停。4.4 在 20 Newsgroups 上的真实实验与结果分析下面是我在一次实验中记录的真实数字任务是从 20 Newsgroups 中区分 comp.graphics 和 sci.space 两个类别。有标签样本控制在 200 条未标注样本 800 条验证集 1000 条。我跑了两组对照Baseline只用 200 条有标签样本训练一个 LogisticRegression直接测试验证集。Co-training同一个 LogisticRegression 作为两个视图的基模型进行 50 轮迭代。最终结果如下方法验证准确率Baseline仅监督学习82.4%Co-training视图1模型86.1%Co-training视图2模型85.3%Co-training两个模型投票87.2%从这个实验结果能读出几个关键结论。第一Co-training 确实把准确率提升了约 4 到 5 个百分点这个涨幅在有标签样本极少时非常可观。第二两个模型投票融合的效果比单模型更好这在实践中是稳定有效的做法。第三视图1和视图2的模型表现存在一定差距这是很正常的因为视图切分并不完全均衡融合可以自然消解这种偏差。5. 参数调优与常见问题排查实录5.1 关键参数的调优经验Co-training 的可调参数不多但每个参数的影响都很大。我从几十次实验里总结出了一些经验k 值每轮新增样本数决定了每轮引入的伪标签数量。k 太小时迭代速度慢模型需要在很多轮之后才能看到足够的新样本k 太大时单轮引入的低置信度样本会直接污染模型。我通常先从 k10 起步观察训练曲线再决定增减。如果准确率稳步上升说明 k 保守了可以适当提高到 20~30如果曲线在后半程掉头说明伪标签噪声在占主导需要调低 k。迭代轮数 T 和早停机制是绑定的。不建议无脑跑满固定轮数因为随着未标注样本不断消耗剩下的样本往往是模型越来越不确定的样本继续加入只会制造噪声。我的做法是每轮记录验证集准确率连续 5 轮没有提升就停止迭代。置信度阈值 conf_threshold 本质上是“伪标签质量”和“样本覆盖量”之间的权衡。阈值设得越高挑出来的样本越可靠但每轮能选中的数量就越少阈值设得越低迭代速度更快但噪声会迅速累积。我在实践中倾向于 0.7~0.95 这个区间。还有一个常被忽略的调参点两个基模型的选择。原论文用朴素贝叶斯因为这个模型对小样本数据相对鲁棒概率输出也相对稳定。但在我的实验中LogisticRegression 在绝大多数数据集上效果更好因为它有正则化参数可以控制模型复杂度在特征维度较大时更抗过拟合。5.2 常见问题与排查思路速查表下面是我整理的一些常见问题及排查建议问题现象可能原因排查建议训练曲线上来就掉准确率低于 baseline伪标签噪声过高提高置信度阈值调小 k检查两个视图是否真的独立迭代很多轮但准确率没有提升视图切分太弱模型无法从对方挑出的样本中获益重新设计视图放弃 Co-training考虑 Tri-training 等变体模型在后期突然崩溃未标注样本被挑选耗尽剩余样本置信度低噪声占比高引入早停机制限制最大迭代轮数提高阈值两个视图模型效果差距过大视图信息量不均衡对弱视图尝试特征增强或更换更适合该视图的模型在特定数据集上不如监督 baseline数据本身不满足双视图独立性假设先将数据做特征相关性分析如果相关系数过高谨慎使用训练耗时过长每轮都重新训练两个模型成本累积减少最大迭代轮数或者采用增量估计替代全量重训5.3 避坑建议为什么效果可能不如预期我遇到过不少读者来问“为什么我复现的 Co-training 效果这么差”拆开来看大部分问题出在数据假设上而不是代码逻辑上。Co-training 的成功建立在视图条件独立这一个前提上如果两个视图其实高度相关那么它本质上就是在做两次几乎一样的预测互相补充的信息量很少效果自然不如想象中好。一个简单的相关度检测方法是先把两个视图拼成一个大特征矩阵在此基础上训练一个模型然后与只用单个视图训练的模型做对比。如果拼接模型的提升极小说明两个视图提供的信息高度重合这时候 Co-training 很可能收益有限不如直接把特征合并走标准监督学习或者自训练。另一个常见坑是类别不平衡。如果原始数据中正负样本相差悬殊Co-training 在每轮挑选样本时容易偏向多数类进而导致伪标签分布失衡。解决办法是调整选样策略比如每轮正负类各挑选固定数量或者在计算置信度时先做类别权重校正。最后想聊一个很多人容易忽视的点Co-training 的训练目标是让模型在两个视图上各自变强但最终应用阶段需要决定到底用哪个模型做推理。我工程上常用的策略有三个一是简单投票二是取两个模型预测概率的平均值三是用一个单独的“融合模型”在验证集上学习两个模型输出与真实标签之间的映射关系。从我的经验来看投票和平均效果就很稳定不需要额外增加复杂度。6. 数据增强之外的思考什么时候该选 Co-training经常有人问我既然现在深度学习模型这么强为什么还要用这种看起来有些“老派”的半监督方法我的回答是方法没有新旧之分只有场景匹配与否。深度学习模型在图像、语音等领域有天然优势但数据标注成本高昂的场景从来不会消失尤其是结构化数据和文本数据上轻量级半监督方法往往能用一个下午就获得明显收益还不需要 GPU 加持。如果你的任务满足下面几条Co-training 值得首选特征可以切分且切分后各视图单独建模都有一定效果。有标签样本极少但未标注样本非常充裕。对解释性有一定要求希望清楚知道模型到底使用了哪些信号做了判断。团队资源有限希望在有限算力下快速跑通一个可用的 baseline。我在实际项目中有一个感受很深的案例一个信贷风控场景里客户信息的特征同时包含结构化字段年龄、收入、职业等和文本字段历史申请留言或客服对话记录。这两个视图天然独立且语义差异巨大我们用它做低违约风险用户的初筛半监督迭代只用了两轮就把有标签样本 1500 条场景下的 AUC 提升了近 4 个百分点。这种效果靠单纯堆模型是不容易获得的Co-training 恰恰发挥了“视图异构”的优势。回到这篇文章开头提到的“几百条标注加几万条无标注”的困惑如果你也正被类似的问题困扰不妨从我给出的代码开始先在你自己的数据上跑一轮看看。你不需要立刻追求最优效果关键是验证一件事两个视图的存在是否真的能让模型看到单视图看不到的信息。如果能Co-training 就会成为你工具包里一个非常顺手的武器。往后遇到特征天然多源的场景你也会第一时间想到它。
返回列表