☰
Python实现文本与多模态风险识别:CLIP+BERT融合实战
2026/10/25 10:39:43 网站建设 项目流程

简介:这份源码面向计算机、人工智能相关专业的课程设计与期末大作业场景,也可作为安全算法竞赛的实战参考,解决文本与多模态数据融合下的风险用户识别问题。项目源自字节跳动安全AI挑战赛色情导流用户识别赛道,围绕词向量训练、数据合并、K折训练、伪标签等环节给出可运行的Python实现,适合具备一定机器学习基础、希望理解多模态风险识别完整流程的学习者。压缩包共11个文件,约88KB,以7个py脚本为核心,配套txt依赖清单、docx说明手册、md说明文档与sh运行脚本,覆盖从环境配置到模型训练的主要环节。目前已有361人学习。读者可据此掌握赛题级方案的整体结构,理解文本特征与多模态信息如何协同用于风险识别,并借鉴K折交叉验证、伪标签等工程技巧,快速搭建自己的实验基线。

1. 文本加多模态做风险识别:为什么单靠一个 BERT 会漏掉一半风险

做过内容风控的同行大概都有过这种经历:文本模型离线指标 AUC 跑到 0.93,上线之后该拦的没拦住,不该拦的误杀一片。翻日志才发现,出问题的样本里大量是「图文不一致」——配文岁月静好,配图却是另一回事;或者反过来,文字里全是黑话,图片看着人畜无害。单模态模型在这种场景下就是个黑匣子,它只看了一半信息,却要给出一个全量判断。

这个项目标题讲的是用 Python 实现一套基于文本和多模态数据的风险识别源码。拆开看,核心是三件事:文本侧做语义风险打分,图像侧做视觉风险特征提取,然后把两路信号融合成一个统一的风险概率。它解决的不是「文本分类怎么做」这种教科书问题,而是真实业务里图文混合内容的风险判定——比如社区帖子、商品详情页、短视频封面加标题、用户举报工单。适合谁看?有 Python 基础、做过至少一个分类任务、现在要处理图文混合数据的工程师。如果你只做过纯文本风控,这套东西能帮你把漏召的那部分补回来。

2. 多模态风险识别的技术选型:为什么是 CLIP 加 BERT 而不是各训各的

2.1 两条技术路线的取舍逻辑

做图文风险识别,绕不开一个架构决策:文本和图像是分别建模再拼接,还是从一开始就用一个联合编码器。分别建模的典型做法是 BERT 出文本向量、ResNet 或 ViT 出图像向量,然后 concat 送进 MLP 分类头。这条路实现简单、训练快、显存友好,但有个硬伤——两个模态之间没有交互,模型学不到「这张图和这段文字放在一起才危险」这种跨模态信号。

联合编码器路线里,CLIP 是目前工程上最成熟的选择。它用对比学习把图像和文本映射到同一个语义空间,天然具备跨模态对齐能力。你拿 CLIP 的 image encoder 和 text encoder 分别抽特征,再做一个轻量的 cross-attention 融合层,就能让模型在训练中自己学到图文之间的风险关联。代价是显存占用明显上升,CLIP ViT-B/32 的视觉塔大概 1.5 亿参数,文本塔 6300 万,加上融合层,单卡 8GB 显存跑 batch size 16 会比较紧张。

我一般会这样选:如果业务数据量在 10 万条以下、风险类型相对固定,走 BERT + ViT 分别编码再融合,性价比最高;如果数据量超过 30 万条、风险模式复杂多变,直接上 CLIP 联合编码,前期投入大但天花板高。还有一个折中方案是用 Chinese-CLIP,它在中文学术和业务语料上做过对齐,比原版 CLIP 对中文场景友好得多。

2.2 数据管线的搭建:从原始图文对到模型输入

不管选哪条路线,数据管线都是第一道坎。真实业务数据通常是这样的:一条记录里有文本字段、一个或多个图片 URL、若干结构化字段(发布时间、用户等级、举报次数)。你需要把它整理成模型能吃的格式。

import os import json import hashlib from pathlib import Path from PIL import Image import requests from io import BytesIO def build_multimodal_dataset(raw_records, image_cache_dir="./image_cache"): """ 将原始图文记录整理为多模态训练样本 raw_records: list of dict, 每条含 text, image_urls, label """ os.makedirs(image_cache_dir, exist_ok=True) samples = [] for rec in raw_records: text = rec.get("text", "").strip() if not text: continue # 图片下载与缓存,用 URL 的 md5 做文件名避免重复下载 image_paths = [] for url in rec.get("image_urls", [])[:3]: # 每条最多取3张图 fname = hashlib.md5(url.encode()).hexdigest() + ".jpg" fpath = os.path.join(image_cache_dir, fname) if not os.path.exists(fpath): try: resp = requests.get(url, timeout=5) img = Image.open(BytesIO(resp.content)).convert("RGB") img = img.resize((224, 224)) # 统一尺寸,CLIP ViT-B/32 输入224 img.save(fpath, "JPEG", quality=85) except Exception as e: print(f"download failed: {url}, {e}") continue image_paths.append(fpath) if not image_paths: continue samples.append({ "text": text, "images": image_paths, "label": rec["label"], # 0 正常,1 风险 "record_id": rec.get("id", "") }) return samples # 使用示例 with open("raw_data.json", "r", encoding="utf-8") as f: raw = json.load(f) dataset = build_multimodal_dataset(raw) print(f"有效样本数: {len(dataset)}")

这段代码做了三件事:文本清洗、图片下载缓存、样本组装。几个关键参数需要根据实际情况调整。[:3]是每条记录最多取三张图,因为大部分风险信号集中在前几张,取太多会稀释注意力且增加显存压力。resize((224, 224))是 CLIP ViT-B/32 的标准输入尺寸,如果你用 ViT-L/14 就要改成 224 但 patch size 不同,实际输入是 224 或 336。timeout=5是下载超时,生产环境建议设 3 到 5 秒,避免个别慢 URL 拖垮整个管线。

注意:图片缓存目录要定期清理,否则跑几十万条数据后磁盘会被撑满。我一般会在管线里加一个 LRU 淘汰逻辑,或者直接用对象存储加本地 SSD 缓存层。

2.3 文本分支的实现:BERT 微调与风险关键词注入

文本分支是整个系统的基线。用bert-base-chinese或hfl/chinese-roberta-wwm-ext做微调,输入就是原始文本,输出是一个二分类概率。但纯 BERT 有个问题:它对黑话、变体、谐音词的识别能力有限,因为这些词在预训练语料里出现频率低。

我的做法是在 BERT 之外挂一个关键词匹配模块,把命中高风险词表的信号作为一个额外特征拼进分类头。词表不需要很大,几百个核心词就够,关键是要持续更新。

import torch import torch.nn as nn from transformers import BertModel, BertTokenizer class TextRiskBranch(nn.Module): def __init__(self, bert_path="bert-base-chinese", num_keywords=500): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.keyword_embed = nn.Embedding(num_keywords, 64) # BERT 输出 768 维 + 关键词 64 维 self.classifier = nn.Sequential( nn.Linear(768 + 64, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, 2) ) def forward(self, input_ids, attention_mask, keyword_ids): # keyword_ids: [batch, num_hit_keywords],命中词索引,未命中填0 bert_out = self.bert(input_ids=input_ids, attention_mask=attention_mask) cls_vec = bert_out.last_hidden_state[:, 0, :] # [batch, 768] kw_vec = self.keyword_embed(keyword_ids).mean(dim=1) # [batch, 64] fused = torch.cat([cls_vec, kw_vec], dim=-1) logits = self.classifier(fused) return logits # 关键词命中检测 def match_keywords(text, keyword2id): hits = [0] * 10 # 最多取10个命中词 idx = 0 for kw, kid in keyword2id.items(): if kw in text and idx < 10: hits[idx] = kid idx += 1 return hits

keyword_embed的维度 64 是我试出来的经验值,太小表达力不够,太大容易过拟合。Dropout(0.3)在文本分支上比较关键,因为风险识别任务的数据往往标注噪声大,dropout 能缓解过拟合。match_keywords里限制最多 10 个命中词,是因为一条文本里真正有信号的关键词通常不超过 5 个,取太多反而引入噪声。

3. 图像与跨模态融合:CLIP 特征怎么接进风险分类头

3.1 图像分支:用 CLIP 视觉塔做特征提取

图像分支不建议从零训 CNN,直接用 CLIP 的 visual encoder 抽特征,冻结底层、只训顶层适配层,收敛快且泛化好。如果你用openai/clip-vit-base-patch32,图像输入是 224x224,输出是 512 维向量。

from transformers import CLIPModel, CLIPProcessor class ImageRiskBranch(nn.Module): def __init__(self, clip_path="openai/clip-vit-base-patch32", freeze_clip=True): super().__init__() self.clip = CLIPModel.from_pretrained(clip_path) if freeze_clip: for param in self.clip.parameters(): param.requires_grad = False # CLIP vision output: 512 维 self.adapter = nn.Sequential( nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.2), nn.Linear(256, 128) ) def forward(self, pixel_values): with torch.no_grad() if not self.training else torch.enable_grad(): vision_out = self.clip.vision_model(pixel_values=pixel_values) pooled = vision_out.pooler_output # [batch, 512] return self.adapter(pooled) # 图像预处理 processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32") def preprocess_images(image_paths): images = [Image.open(p).convert("RGB") for p in image_paths] inputs = processor(images=images, return_tensors="pt", padding=True) return inputs["pixel_values"]

freeze_clip=True是默认选择,因为 CLIP 预训练特征已经很强,微调反而容易在小数据集上崩掉。adapter把 512 维压到 128 维,是为了和文本分支的输出维度匹配,方便后续融合。如果你显存够,可以把freeze_clip设为 False 并只解冻最后两层 transformer block,学习率设到 1e-5 量级。

3.2 跨模态融合:三种融合策略的对比与选择

文本分支输出 256 维(分类头之前),图像分支输出 128 维,融合方式直接决定模型上限。我试过三种:

融合方式实现复杂度显存开销适用场景
拼接加 MLP低小数据量小、模态关联弱
Cross-Attention中中图文关联强、数据量中等
门控融合中小模态质量不稳定、需要动态加权

拼接加 MLP 最简单,把两个向量 concat 后过几层全连接。Cross-Attention 让文本向量去 attend 图像向量,能学到细粒度关联,但训练不稳定。门控融合是我目前最推荐的,它用一个可学习的门控网络动态决定每个样本里文本和图像各占多少权重。

class GatedFusion(nn.Module): def __init__(self, text_dim=256, image_dim=128, hidden=128): super().__init__() self.gate = nn.Sequential( nn.Linear(text_dim + image_dim, hidden), nn.ReLU(), nn.Linear(hidden, 2), nn.Softmax(dim=-1) ) self.final_classifier = nn.Sequential( nn.Linear(text_dim + image_dim, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 2) ) def forward(self, text_feat, image_feat): concat = torch.cat([text_feat, image_feat], dim=-1) gate_weights = self.gate(concat) # [batch, 2] # 加权融合 weighted = torch.cat([ text_feat * gate_weights[:, 0:1], image_feat * gate_weights[:, 1:2] ], dim=-1) return self.final_classifier(weighted), gate_weights

门控权重的输出可以拿来分析:如果某个样本的文本权重接近 1、图像权重接近 0,说明模型认为文本信号更可靠。上线后可以监控这个分布,如果发现图像权重长期偏低,说明图像分支没学好或者图像质量有问题。

3.3 训练策略:分阶段训练与损失函数设计

多模态模型不建议一上来就端到端训。我的做法是分三阶段:第一阶段单独训文本分支,让它收敛到一个合理基线;第二阶段冻结文本分支,单独训图像分支的 adapter;第三阶段解冻融合层和两个分支的顶层,用较小学习率联合微调。

损失函数用带类别权重的交叉熵,因为风险识别任务里正负样本通常不均衡。如果正样本占比只有 5%,给正类权重 5 到 10 倍。

def train_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0 for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) keyword_ids = batch["keyword_ids"].to(device) pixel_values = batch["pixel_values"].to(device) labels = batch["label"].to(device) optimizer.zero_grad() logits, gate_weights = model(input_ids, attention_mask, keyword_ids, pixel_values) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() return total_loss / len(dataloader) # 类别权重计算 from sklearn.utils.class_weight import compute_class_weight import numpy as np labels = [s["label"] for s in dataset] class_weights = compute_class_weight("balanced", classes=np.array([0, 1]), y=labels) criterion = nn.CrossEntropyLoss(weight=torch.tensor(class_weights, dtype=torch.float))

clip_grad_norm_的 max_norm 设 1.0 是防止梯度爆炸,多模态模型里 cross-attention 层容易出这个问题。学习率方面,文本分支用 2e-5,图像 adapter 用 1e-4,融合层用 5e-4,分参数组设置。

4. 避坑与排查:多模态风险识别里最容易翻车的五个地方

4.1 图片下载失败导致样本静默丢失

现象:训练集明明有 10 万条,跑完管线只剩 6 万条,模型指标虚高。

原因:build_multimodal_dataset里图片下载失败时直接continue,把整条样本丢了。如果失败集中在某类图片(比如某些域名的图床),模型就学不到那部分分布。

解决:下载失败时不要丢样本,而是用一张纯灰图占位,同时记录一个image_missing标志位,在融合层里把这个标志作为一个额外特征输入,让模型自己决定是否忽略图像分支。

4.2 文本长度截断把关键信号切掉

现象:某些风险文本明明有关键词,模型却判正常。

原因:BERT 默认 max_length 是 512,但中文长文本(比如商品详情页)经常超过这个长度,关键风险词可能在后半段被截掉。

解决:不要简单截断,用滑动窗口取前 256 和后 256 个 token,分别过 BERT 后取平均。或者先用关键词匹配定位风险词位置,优先保留包含风险词的片段。

4.3 图像分支过拟合到背景颜色

现象:验证集准确率 0.95,测试集掉到 0.7。

原因:训练集里风险样本的图片可能集中在某几种背景色或拍摄风格上,模型学到了「背景暗就是风险」这种伪相关。

解决:训练时加颜色抖动、随机裁剪、随机翻转等增强。更关键的是做分层采样,确保每个 batch 里正负样本的图像风格分布接近。我一般会用albumentations做增强,比 torchvision 的 transform 更灵活。

4.4 门控融合退化成单模态

现象:训练日志里 gate_weights 某一维长期接近 1,另一维接近 0。

原因:两个模态的特征尺度不一致,文本特征方差大,图像特征方差小,门控网络倾向于选信号强的那个。

解决:在融合前对两个模态特征做 LayerNorm,把尺度拉到同一量级。另外可以在损失里加一个正则项,惩罚门控权重过于极端的情况。

4.5 推理时 batch 内 padding 导致结果不一致

现象:同一条样本单独推理和批量推理结果不一样。

原因:文本 padding 和图像 padding 在 batch 内对齐时,attention mask 没处理好,padding 位置参与了注意力计算。

解决:确保attention_mask正确传入 BERT,图像 processor 的padding=True要配合return_tensors="pt"使用。推理时如果 batch size 变化,最好固定 padding 到 max_length,而不是动态 padding。

5. 进阶技巧:用风险类型解耦和置信度校准把误杀率压下去

走到这一步,模型能跑通、指标也还行,但上线后你会发现一个更棘手的问题:误杀。风险识别和普通分类不一样,误杀的代价有时候比漏召还大。我踩过的坑是模型把所有「看起来像风险」的样本都判成风险,召回上去了,精确率崩了。

第一个技巧是风险类型解耦。不要只做一个二分类,而是把风险拆成若干子类型(比如广告、辱骂、违禁品),每个子类型单独出一个概率,最后用一个规则层聚合。这样做的好处是每个子类型的决策阈值可以独立调,广告类可以宽松一点,违禁品类必须严格。实现上就是在融合层后面挂多个分类头,训练时用多任务损失。

class MultiHeadRiskModel(nn.Module): def __init__(self, fusion_dim=384, num_risk_types=5): super().__init__() self.shared = nn.Sequential( nn.Linear(fusion_dim, 256), nn.ReLU(), nn.Dropout(0.3) ) # 每个风险类型一个独立分类头 self.heads = nn.ModuleList([ nn.Linear(256, 2) for _ in range(num_risk_types) ]) # 全局风险头 self.global_head = nn.Linear(256, 2) def forward(self, fused_feat): shared = self.shared(fused_feat) type_logits = [head(shared) for head in self.heads] global_logits = self.global_head(shared) return type_logits, global_logits # 多任务损失 def multi_task_loss(type_logits, global_logits, type_labels, global_label, weights): loss = 0 for i, logits in enumerate(type_logits): loss += weights[i] * nn.functional.cross_entropy(logits, type_labels[:, i]) loss += nn.functional.cross_entropy(global_logits, global_label) return loss

第二个技巧是置信度校准。模型输出的 softmax 概率往往过于自信,0.9 的概率实际准确率可能只有 0.7。用温度缩放(temperature scaling)在验证集上拟合一个温度参数,把概率校准到接近真实置信度。这样你可以设一个「灰区」——概率在 0.4 到 0.6 之间的样本送人工审核,而不是硬判。

class TemperatureScaler(nn.Module): def __init__(self): super().__init__() self.temperature = nn.Parameter(torch.ones(1) * 1.5) def forward(self, logits): return logits / self.temperature # 在验证集上拟合温度 def fit_temperature(model, val_loader, device): scaler = TemperatureScaler().to(device) optimizer = torch.optim.LBFGS([scaler.temperature], lr=0.01, max_iter=50) nll = nn.CrossEntropyLoss() def closure(): optimizer.zero_grad() total_loss = 0 for batch in val_loader: logits = model(batch) # 获取未校准的 logits calibrated = scaler(logits) total_loss += nll(calibrated, batch["label"].to(device)) total_loss.backward() return total_loss optimizer.step(closure) return scaler.temperature.item()

温度值一般在 1.2 到 2.5 之间,如果拟合出来超过 3,说明模型过拟合严重,得回头检查训练数据。

第三个技巧是推理时的模态丢弃测试。上线前我会做一组实验:只用文本推理、只用图像推理、两个都用,对比三组结果的差异。如果只用文本的指标和两个都用的指标差距在 2 个点以内,说明图像分支没贡献,得回去检查图像特征是不是被门控网络忽略了。这个测试能帮你快速定位是融合层的问题还是某个分支本身没学好。

最后说一个习惯:每次改完模型结构或训练策略,我都会固定一个 500 条的「金标集」跑一遍,记录精确率、召回率和灰区比例。金标集不参与训练,只用来做版本对比。这个习惯帮我避免了好几次「指标涨了但实际效果变差」的翻车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询