☰
基于word2vec与Transformer的英文影像报告分类实战
2026/10/15 7:53:53 网站建设 项目流程

简介:本资源面向具备一定深度学习基础的NLP学习者与医学文本挖掘研究者,提供一套基于MIMIC-IV英文影像报告的多类别文本分类实战方案。核心思路是借助gensim的word2vec训练词向量,再在PyTorch中搭建Transformer网络完成分类建模,覆盖从文本提取、数据预处理到模型训练与测试的完整链路。压缩包共20个文件,约2.96MB,以8个Python脚本为主体,辅以5个pyc缓存、2个csv数据文件、1个bin词向量、1个pth权重、1个model模型及txt、xlsx等说明与编码文件,结构紧凑、模块划分清晰。目前已有516人学习下载。读者可据此掌握词向量与Transformer结合的工程实现方式,理解医学报告文本的清洗、分词与标签构建流程,并参考其中的模型定义、训练与测试脚本快速复现实验,适合作为课程设计、科研入门或医学NLP项目的实践参考。

1. 从一份英文影像报告到分类标签:这个项目到底在做什么

手里有一批英文影像报告,每份都是自由文本,放射科医生写的描述长短不一,有的三五行,有的十几行,还夹着大量专业缩写和固定句式。现在要把它们自动分到几个预定义的类别里——比如按检查部位分、按有无异常分、按紧急程度分。这个项目就是干这件事的:用 word2vec 做词向量预训练,再用 transformer 做文本分类,整套流程跑在 PyTorch 上。

它适合两类人:一类是刚接触 NLP 分类任务、想找一个完整可跑 pipeline 的开发者,另一类是手里有医学文本数据、想快速搭一个 baseline 再迭代的工程同学。项目本身不依赖特定数据集,你把报告文本换成别的领域英文语料,改改标签体系,照样能跑。下面我从目录结构开始拆,把每个模块的代码和参数都讲清楚。

2. 项目结构与技术选型:为什么是 word2vec + transformer

2.1 目录结构长什么样

拿到源码包后,先看整体布局。一个典型的工程化组织方式是这样的:

project/ ├── configs/ │ └── config.yaml # 超参数、路径、模型配置 ├── data/ │ ├── raw/ # 原始报告文本 │ └── processed/ # 分词、清洗后的数据 ├── src/ │ ├── data_loader.py # 数据集加载与批处理 │ ├── tokenizer.py # 分词与词表构建 │ ├── word2vec_train.py # 词向量训练 │ ├── model.py # transformer 分类模型 │ ├── train.py # 训练循环 │ └── predict.py # 推理入口 ├── checkpoints/ # 模型权重保存 ├── requirements.txt └── README.md

这个结构的好处是数据和代码分离,配置外置。我一般会把所有路径和超参数塞进config.yaml,训练脚本只读配置,不硬编码。这样换数据集时只改配置,不动代码。

2.2 为什么用 word2vec 而不是直接上 BERT

这是很多人会问的点。项目选 word2vec + transformer 的组合,核心原因是数据量和算力。影像报告虽然专业,但标注数据往往只有几千到几万条,直接微调预训练语言模型容易过拟合,而且推理成本高。word2vec 在无标注报告上先训出领域词向量,再喂给 transformer,等于用低成本方式注入了领域知识。

常见做法是:word2vec 用 Skip-gram 模式,窗口设 5,向量维度 128 或 256,负采样 5 到 10 个。训练完后,词向量矩阵直接作为 transformer 的 embedding 初始化权重。这样即使分类标注数据不多,embedding 层也不是随机初始化的。

2.3 数据加载与词表构建

先看分词和词表部分。影像报告里有很多固定搭配,比如 "no acute abnormality"、"within normal limits",分词时不能简单按空格切。

import re from collections import Counter def clean_report(text): # 转小写,去掉多余空白和特殊符号 text = text.lower() text = re.sub(r'[^a-z0-9\s\.\,\-]', ' ', text) text = re.sub(r'\s+', ' ', text).strip() return text def tokenize(text): # 按空格和标点切分,保留医学缩写中的连字符 tokens = re.findall(r'[a-z]+(?:-[a-z]+)*|\d+\.?\d*', text) return tokens def build_vocab(token_lists, min_freq=2): counter = Counter() for tokens in token_lists: counter.update(tokens) # 特殊标记:padding、unknown、cls vocab = {'<pad>': 0, '<unk>': 1, '<cls>': 2} for word, freq in counter.most_common(): if freq >= min_freq: vocab[word] = len(vocab) return vocab

clean_report负责归一化,tokenize用正则保留连字符和数字,build_vocab过滤低频词。min_freq=2是个经验值,太低会引入噪声,太高会让太多词变成<unk>。影像报告里 "CT"、"MRI" 这类词频率很高,不会被过滤。

2.4 word2vec 训练的关键参数

word2vec 部分用gensim实现最省事,但项目里如果要求纯 PyTorch,也可以自己写 Skip-gram。下面是 gensim 版本的训练代码:

from gensim.models import Word2Vec def train_word2vec(token_lists, vector_size=128, window=5, sg=1): model = Word2Vec( sentences=token_lists, vector_size=vector_size, # 词向量维度 window=window, # 上下文窗口大小 min_count=2, # 忽略低频词 sg=sg, # 1 表示 Skip-gram,0 表示 CBOW negative=5, # 负采样数量 epochs=10, # 训练轮数 workers=4 # 并行线程 ) return model

vector_size设 128 是平衡表达能力和内存的常见选择。sg=1用 Skip-gram,对低频词效果更好,适合医学文本。negative=5是负采样默认值,数据量大时可以调到 10。训练完后,把词向量按词表顺序导出成矩阵,存成.npy文件,供模型加载。

3. Transformer 分类模型:从 embedding 到分类头

3.1 模型整体结构

模型分三层:embedding 层(用 word2vec 初始化)、transformer encoder 层、分类头。输入是 token id 序列,输出是类别 logits。

import torch import torch.nn as nn import numpy as np class ReportClassifier(nn.Module): def __init__(self, vocab_size, embed_dim, num_heads, hidden_dim, num_layers, num_classes, dropout=0.1, pretrained_emb=None): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) if pretrained_emb is not None: # 用 word2vec 权重初始化,冻结前几轮可选 self.embedding.weight.data.copy_(torch.from_numpy(pretrained_emb)) self.pos_encoder = PositionalEncoding(embed_dim, dropout) encoder_layer = nn.TransformerEncoderLayer( d_model=embed_dim, nhead=num_heads, dim_feedforward=hidden_dim, dropout=dropout, batch_first=True ) self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.classifier = nn.Sequential( nn.Linear(embed_dim, hidden_dim), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dim, num_classes) ) def forward(self, input_ids, attention_mask): x = self.embedding(input_ids) x = self.pos_encoder(x) # 把 padding 位置 mask 掉,避免注意力分配到无意义 token x = self.transformer(x, src_key_padding_mask=~attention_mask.bool()) # 取 <cls> 位置的输出做分类 cls_out = x[:, 0, :] logits = self.classifier(cls_out) return logits

embedding层用预训练矩阵初始化,padding_idx=0保证 padding 不参与梯度。pos_encoder加位置信息,因为 transformer 本身没有顺序概念。src_key_padding_mask是关键,不加的话模型会关注到 padding 位置,效果明显下降。分类时取<cls>位置的输出,这是 BERT 风格的常见做法。

3.2 位置编码的实现

import math class PositionalEncoding(nn.Module): def __init__(self, d_model, dropout=0.1, max_len=512): super().__init__() self.dropout = nn.Dropout(dropout) pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len).unsqueeze(1).float() div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) self.register_buffer('pe', pe.unsqueeze(0)) def forward(self, x): x = x + self.pe[:, :x.size(1), :] return self.dropout(x)

标准正弦位置编码,max_len=512对影像报告足够,单份报告很少超过 512 个 token。register_buffer保证pe不参与梯度更新,但会随模型保存和加载。

3.3 数据集与批处理

from torch.utils.data import Dataset, DataLoader class ReportDataset(Dataset): def __init__(self, texts, labels, vocab, max_len=256): self.texts = texts self.labels = labels self.vocab = vocab self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens = tokenize(clean_report(self.texts[idx])) ids = [self.vocab.get('<cls>')] + \ [self.vocab.get(t, self.vocab['<unk>']) for t in tokens] ids = ids[:self.max_len] mask = [1] * len(ids) # padding 到 max_len ids += [0] * (self.max_len - len(ids)) mask += [0] * (self.max_len - len(mask)) return { 'input_ids': torch.tensor(ids, dtype=torch.long), 'attention_mask': torch.tensor(mask, dtype=torch.long), 'label': torch.tensor(self.labels[idx], dtype=torch.long) }

max_len=256是截断长度,超过就截掉。<cls>放在开头,对应模型里取x[:, 0, :]。attention_mask标记真实 token 和 padding,传给模型做 mask。

3.4 训练循环与评估

def train_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 for batch in loader: input_ids = batch['input_ids'].to(device) mask = batch['attention_mask'].to(device) labels = batch['label'].to(device) optimizer.zero_grad() logits = model(input_ids, mask) loss = criterion(logits, labels) loss.backward() # 梯度裁剪,防止 transformer 训练不稳定 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() return total_loss / len(loader)

clip_grad_norm_是 transformer 训练的标配,max_norm=1.0能有效防止梯度爆炸。优化器一般用 AdamW,学习率 1e-4 到 3e-4,配合 warmup。评估时算准确率和 F1,类别不均衡时看 F1 更有意义。

4. 跑起来:环境、配置与训练命令

4.1 环境依赖

pip install torch numpy gensim pyyaml scikit-learn tqdm

如果要用 GPU,装对应 CUDA 版本的 PyTorch。gensim只用来训 word2vec,如果自己实现 Skip-gram 可以去掉。scikit-learn用来算分类指标。

4.2 配置文件

data: raw_path: data/raw/reports.csv text_col: report_text label_col: category max_len: 256 val_ratio: 0.2 word2vec: vector_size: 128 window: 5 sg: 1 min_count: 2 epochs: 10 model: embed_dim: 128 num_heads: 4 hidden_dim: 256 num_layers: 2 num_classes: 5 dropout: 0.1 train: batch_size: 32 lr: 2e-4 epochs: 20 device: cuda

num_heads=4、num_layers=2是中小数据集的常见配置,层数太多容易过拟合。num_classes按你的标签体系改。batch_size=32在 8G 显存上跑 256 长度没问题。

4.3 完整训练流程

# 1. 训练 word2vec python src/word2vec_train.py --config configs/config.yaml # 2. 训练分类模型 python src/train.py --config configs/config.yaml # 3. 推理 python src/predict.py --config configs/config.yaml --text "no acute cardiopulmonary abnormality"

三步走:先训词向量,再训分类模型,最后推理。word2vec_train.py会把词向量矩阵存到data/processed/emb.npy,train.py加载它初始化 embedding。

4.4 训练时看什么指标

训练日志里重点看三个数:训练 loss、验证 loss、验证 F1。训练 loss 降但验证 loss 升,说明过拟合,加 dropout 或减层数。两个 loss 都不降,检查学习率是不是太大,或者数据有没有问题。F1 比准确率更能反映类别不均衡时的真实效果。

5. 避坑与常见问题排查

5.1 词向量加载后 loss 不降

现象:训练几个 epoch,loss 一直在 2.3 左右不动。原因:词向量矩阵和词表顺序没对齐,embedding 加载错了。解决:检查build_vocab生成的词表顺序和word2vec导出矩阵时的顺序是否一致,用model.wv.key_to_index对齐。

5.2 验证集 F1 波动大

现象:每个 epoch 的 F1 上下跳 0.1 以上。原因:验证集太小,或者 batch 里类别分布不均。解决:增大验证集比例到 0.2,训练时用WeightedRandomSampler做类别平衡采样。

5.3 推理时结果全是同一类

现象:预测输出全是多数类。原因:类别极度不均衡,模型学到了偷懒策略。解决:损失函数加类别权重,CrossEntropyLoss(weight=class_weights),权重按类别频率倒数算。

5.4 显存不够

现象:CUDA out of memory。原因:max_len或batch_size太大。解决:先把batch_size降到 16,再不行把max_len从 256 降到 128。transformer 的显存占用和序列长度平方相关,降长度效果最明显。

5.5 分词后<unk>太多

现象:大量 token 变成<unk>。原因:min_count设太高,或者清洗时把有用字符去掉了。解决:把min_count降到 1,检查clean_report的正则是不是误删了数字和连字符。

6. 二次开发:换数据、换模型、换任务的改造思路

6.1 换数据集要改哪些地方

换数据时,改config.yaml里的raw_path、text_col、label_col和num_classes。如果新数据的文本格式差异大,调整clean_report和tokenize里的正则。比如报告里有大量数字指标,就把数字保留规则写细一点。词表构建不用动,build_vocab会自动适配新语料。

6.2 把 word2vec 换成预训练语言模型

如果想上 BERT 类模型,把ReportClassifier的 embedding 层和 transformer 层替换成transformers库的AutoModel,分类头保留。word2vec 训练步骤可以跳过,直接用 tokenizer 的输出。注意 BERT 的[CLS]位置和项目里的<cls>对应,取last_hidden_state[:, 0, :]即可。

6.3 从单标签分类扩展到多标签

影像报告经常一份报告对应多个标签。改法:把CrossEntropyLoss换成BCEWithLogitsLoss,分类头输出维度改成标签数,每个位置独立过 sigmoid。评估时用sklearn.metrics.f1_score(average='micro')或macro。

6.4 一个实用技巧:用验证集调阈值

多标签任务里,sigmoid 输出 0.5 不一定是最佳阈值。我一般会在验证集上跑一遍,对每个类别单独搜阈值,从 0.3 到 0.7 步长 0.05,取 F1 最高的那个。这个操作能涨 2 到 3 个点,成本很低。

def search_thresholds(logits, labels, num_classes): probs = torch.sigmoid(logits).numpy() labels = labels.numpy() thresholds = {} for c in range(num_classes): best_f1, best_t = 0, 0.5 for t in np.arange(0.3, 0.75, 0.05): preds = (probs[:, c] >= t).astype(int) f1 = f1_score(labels[:, c], preds, zero_division=0) if f1 > best_f1: best_f1, best_t = f1, t thresholds[c] = best_t return thresholds

zero_division=0防止某类没有正样本时报错。搜出来的阈值存下来,推理时按类别用不同阈值。

6.5 模型保存与加载的坑

保存时用torch.save(model.state_dict(), path),加载时先实例化模型再load_state_dict。注意config.yaml里的模型参数必须和保存时一致,否则state_dict对不上。我习惯把配置和权重存在同一个目录,加载时先读配置再建模型。

从那以后我每次换数据集,都强制先跑一遍build_vocab和词向量加载的单元检查,确认词表对齐、embedding 矩阵形状匹配,再开训练。这个习惯帮我省了很多次白跑一晚上的电费。希望帮到你。

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

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

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

立即咨询