DeepSeek医疗内网本地化部署与微调实战
简介:面向医疗信息化从业者、算法工程师与医院数据平台建设者,这份PDF文档围绕DeepSeek本地化部署与医疗数据私有化训练展开,重点解决敏感病历在合规环境下如何完成模型训练与应用落地的问题。内容从私有化部署背景与法规要求切入,覆盖服务器与GPU选型、Ubuntu与Anaconda环境搭建、PyTorch与CUDA配置、MySQL病历数据存储,以及数据收集、清洗、标注和训练集划分、模型加载等关键环节,并给出可参考的命令与代码片段。资源包仅含1个PDF文件,大小约228KB,篇幅紧凑,适合作为方案梳理与实施路线参考。已有1347人浏览学习,读者可据此理解医疗数据本地化训练的整体流程,掌握环境准备、数据处理与模型训练之间的衔接思路,并借鉴硬件配置与合规安全要点,为科室或企业的私有化深度学习项目提供可操作的起步框架。
1. 从一台裸机开始:医疗数据为什么必须留在本地
模拟项目X 第一次需求评审,数据方只提了一条硬条件:模型权重、训练日志、推理请求,一个字节都不许出内网。这不是矫情,病历里的主诉和出院小结随手一搜就能定位到具体的人,脱敏做不干净,后面所有的准确率都没有意义。所以只有一条路:做 DeepSeek 本地化部署,把深度学习的训练闭环整个搬进机房,PyTorch 跑在内网的加速卡上,医疗数据从数据库出来到变成张量为止,不出机架。
这份方案要解决的就是这件事:从裸机开始,把驱动、CUDA、PyTorch、病历库、数据管道、微调训练、评估灰度串成一条能复现的线。适合两类人看:一类是手里有卡但没跑过大模型微调任务的医疗信息化工程师,一类是被合规要求卡住、只能在内网做实验的算法同学。下面的命令和参数都按能跑通的标准写,卡数和模型规模按自己的预算折算。
2. 环境落地:CUDA、PyTorch 与显存预算的一次对齐
环境这一章最容易翻车的地方不在装不上,而在装上了却跑不满。同一个 7B 模型,有人两张 80GB 卡就能全参微调,有人四张卡还 OOM,差别全在「显存预算算没算过」和「版本对不对齐」这两件事上。我一般会把这两件事放在买卡之前做,因为算完账你大概率会改方案。
2.1 先把显存预算算清楚,再决定买几张卡
显存占用的粗略公式:全参微调用 fp16 混合精度 + AdamW,每个参数大约吃 16 字节(权重 2 + 梯度 2 + fp32 主权重 4 + 一阶二阶动量 8),再叠加激活值。激活值跟 batch 和序列长度强相关,512 长度、batch 8 的情况下,7B 模型的激活大约再吃 10 到 20GB。按这个公式估一遍,比拍脑袋买卡靠谱得多。
| 训练方式 | 7B 级别 | 14B 级别 | 32B 级别 | 适用场景 |
|---|---|---|---|---|
| 全参 fp16 + AdamW | 约 112GB 起 | 约 224GB 起 | 约 512GB 起 | 数据量大、要改模型底层表达 |
| LoRA(r=8)fp16 | 约 20GB | 约 34GB | 约 70GB | 分类、抽取类任务的主力方案 |
| QLoRA 4bit | 约 10GB | 约 18GB | 约 36GB | 单卡实验、快速验证可行性 |
我一般会在这个估算基础上再留 20% 余量,因为实际跑起来还有 CUDA context、碎片化和 eval 阶段的额外开销。双路 32 核 CPU、512GB 内存、两张 80GB 加速卡这个配置,对 7B 全参和 14B LoRA 都是够的;存储上系统盘做 RAID 1 保启动,数据盘做 RAID 5 存病历和权重,再挂一块 NVMe 做缓存,加载 checkpoint 的时候差别很明显。
2.2 系统层:驱动、CUDA 与 Conda 一次装干净
内网机器装环境的痛点是缺依赖,所以第一次装的时候把编译工具链一起装上,后面装 tokenizers、装数据库客户端都不用再折腾。顺序是:系统更新 → 驱动 → Conda → PyTorch → 训练侧依赖。
# 1) 系统基线:更新 + 编译工具链,后面编译扩展要用 sudo apt update && sudo apt -y upgrade sudo apt -y install build-essential git curl pkg-config libssl-dev \ linux-headers-$(uname -r) # 2) 显卡驱动:装完必须 reboot,nvidia-smi 能出表才算过 # 驱动包按加速卡型号走官方渠道拿,内网环境提前下好 sudo reboot nvidia-smi # 3) Conda:-b 静默装,-p 指定路径,避免装到 home 下撑爆系统盘 bash Anaconda3-2023.09-0-Linux-x86_64.sh -b -p /opt/anaconda3 source /opt/anaconda3/bin/activate conda create -n deepseek_medical python=3.10 -y conda activate deepseek_medical # 4) PyTorch:cu118 轮子,版本必须和驱动支持的 CUDA 上限对齐 pip install torch torchvision torchaudio \ --index-url https://download.pytorch.org/whl/cu118 # 5) 训练侧依赖 + 锁版本,内网复现全靠这份 lock pip install transformers datasets accelerate peft scikit-learn \ pandas pyarrow matplotlib seaborn pymysql cryptography pip freeze > requirements.lock这里有三处值得展开。一是-b -p /opt/anaconda3,默认安装会把 5GB 左右的包塞进/root,系统盘是 RAID 1 的 SSD,塞满以后 MySQL 写临时文件会报错,这个坑我踩过一次,排查了半天才发现是磁盘满。二是--index-url指向 cu118 索引,如果你的驱动只支持到 CUDA 11.x,就必须用 cu118,用 cu121 的轮子运行时会报 no kernel image available。三是最后的pip freeze,内网机器不能随时重装,把 lock 文件一起归档,三个月后回来复现实验能省一整天。
2.3 装完先跑三行自检,别急着导数据
环境装好不等于能用。我习惯在导任何数据之前先跑一段自检脚本,确认三件事:PyTorch 认不认卡、卡能不能跑满、显存有没有被别的进程偷偷占着。
import time, torch # 1) 版本与可见性 print("torch:", torch.__version__, "cuda:", torch.version.cuda) print("available:", torch.cuda.is_available(), "count:", torch.cuda.device_count()) for i in range(torch.cuda.device_count()): print(i, torch.cuda.get_device_name(i), "%.0f GB" % (torch.cuda.get_device_properties(i).total_memory / 1024**3)) # 2) 算力自检:4096 方阵乘 20 次,看耗时和峰值显存 dev = torch.device("cuda:0") a = torch.randn(4096, 4096, device=dev, dtype=torch.float16) torch.cuda.synchronize(); t0 = time.time() for _ in range(20): a = a @ a torch.cuda.synchronize() print("20 次 matmul: %.2fs" % (time.time() - t0)) print("峰值显存: %.1f GB" % (torch.cuda.max_memory_allocated() / 1024**3)) # 3) 分配器状态:确认初始占用很低,没有别的进程在吃卡 print("已保留: %.2f GB" % (torch.cuda.memory_reserved(dev) / 1024**3))判读方式很直接:count应该是 2;20 次 matmul 在两张 80GB 卡上正常是秒级完成,如果拖到十几秒,多半是卡被限了功耗或者驱动没装对;峰值显存应该稳定在 0.5GB 上下,如果一开始就显示十几 GB 被占,说明有残留进程,后面训练一定 OOM。这套自检我每次换机器都跑一遍,比看硬件清单快。
2.4 病历库:建表、最小权限与标注状态位
数据库这一层大多数人随手就root加localhost了事,但内网多人共用一台机器的时候,权限隔离是必须的。训练脚本用的账号只给单库读写,不要给 DROP。
CREATE DATABASE medical_data DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 训练服务账号:只给单库读写,别用 root 跑训练脚本 CREATE USER 'train_svc'@'10.0.%' IDENTIFIED BY '换成强口令'; GRANT SELECT, INSERT, UPDATE ON medical_data.* TO 'train_svc'@'10.0.%'; FLUSH PRIVILEGES; USE medical_data; CREATE TABLE visit_record ( visit_id BIGINT PRIMARY KEY AUTO_INCREMENT, patient_uid CHAR(36) NOT NULL COMMENT '脱敏后的患者主键,不用住院号', visit_date DATE NOT NULL, chief_complaint TEXT NULL COMMENT '主诉', diagnosis VARCHAR(64) NOT NULL COMMENT '标签列,必须人工确认', report_text MEDIUMTEXT NULL COMMENT '检查报告/出院小结原文', label_status TINYINT NOT NULL DEFAULT 0 COMMENT '0未标注 1已标注 2存疑', KEY idx_patient (patient_uid), KEY idx_status (label_status) ) ENGINE=InnoDB;字段设计上有两个决定值得说。patient_uid用 UUID 而不是住院号,因为住院号在各院区之间会重号,而且住院号本身带地区编码信息。label_status是为了让标注质量可追溯:导出训练集时只取label_status = 1,存疑样本单独放到一张表里,等第二个人复核。utf8mb4必须用,病历里生僻字和特殊符号不少,用 utf8 会在导入阶段直接截断,而且不报错。
3. 数据管道:把病历原表洗成能进 Trainer 的张量
模型侧的问题大多能靠调参救回来,数据侧的问题救不回来。这一章的四步——清洗、定标签、划分、编码——每一步都有一个「看起来对但实际错」的默认写法,我把它们都标出来了。
3.1 清洗:一行 dropna 会顺手删掉你的少数类
df.dropna()是最常见的误用。它的行为是「任意一列有空就整行删」,而病历表里检查报告为空的比例往往超过一半,一行代码下去,样本直接少一半,而且少掉的恰好是那些还没做检查的早期病例——正是最有价值的那批。
import pandas as pd df = pd.read_csv("medical_records.csv", dtype={"patient_uid": str, "diagnosis": str}) # 1) 文本列:统一空白和换行,空值填空串而不是删行 text_cols = ["chief_complaint", "report_text"] for c in text_cols: df[c] = (df[c].fillna("").astype(str) .str.replace(r"\s+", " ", regex=True).str.strip()) # 2) 只有标签缺失的才删,特征缺失的保留 df = df[df["diagnosis"].notna() & (df["diagnosis"] != "")] # 3) 主文本过短的丢掉:这类基本是“无”“未见异常”的噪声 df = df[(df["chief_complaint"].str.len() + df["report_text"].str.len()) >= 8] # 4) 去重按患者+就诊日期,保留最后一条(通常是修订后的版本) df = (df.sort_values("visit_date") .drop_duplicates(subset=["patient_uid", "visit_date"], keep="last")) # 5) 脱敏兜底:正则命中的一律替换成占位符 patterns = { r"\d{17}[\dXx]": "<ID>", r"1[3-9]\d{9}": "<PHONE>", r"[A-Za-z]:\\\\[^\s]+": "<PATH>", } for pat, tag in patterns.items(): for c in text_cols: df[c] = df[c].str.replace(pat, tag, regex=True) df.to_parquet("stage1_clean.parquet", index=False) print(df["diagnosis"].value_counts().head(20)) # 盯一眼长尾关于参数:keep="last"配合sort_values("visit_date"),保证同一患者同一天多次写入时留最新版;阈值>= 8是按中文病历的常态定的,太短的主诉基本不携带分类信息;最后那段正则只是兜底,真正的姓名脱敏靠映射表,因为姓名没有固定格式,正则匹配不了。输出用 parquet 而不是 csv,是为了保留 dtype 并省掉反复解析文本的时间,百万行量级差别很明显。
最后那行value_counts()是整个流程里最重要的一次输出,它决定你后面所有指标的解读方式。头部几个类占 80%、尾巴上十几个类各只有几十条,这是病历数据的常态。
3.2 标签体系:先合并长尾,再谈准确率
拿到value_counts之后,不要直接开训,先做一次标签归并。我的经验线是:单个类别样本数低于 200 的,先合并到上级类;合并要按临床处置逻辑走,不能只看字面像。
| 原始标签(示例) | 合并后 | 判定依据 |
|---|---|---|
| 2 型糖尿病 / 2 型糖尿病伴并发症 / 血糖调节异常 | 糖代谢异常 | 处置路径一致,亚型样本各不足 50 |
| 上呼吸道感染 / 急性咽炎 / 急性扁桃体炎 | 呼吸道感染 | 门诊处理方式相同 |
| 原发性高血压 / 高血压 2 级 / 高血压 3 级 | 高血压 | 分级信息作为特征保留,不作标签 |
| 疑似冠心病 / 冠状动脉粥样硬化 | 冠心病 | 前者是后者的初筛表述 |
合并完再做一次标注一致性检查:抽 200 条让两个人独立标,算 Cohen's kappa,低于 0.7 就回去改标注手册,不要硬训。模型的上限就是标注质量,标注本身不一致的时候,验证集 F1 卡在 0.7 上不去是必然的,调参救不回来。
3.3 7:1:2 划分:必须按患者分,不能按行分
这是医疗数据和公开数据集最大的差别。同一个患者可能有一年内的五次就诊记录,如果按行随机划分,这些记录会同时落在训练集和验证集里,模型学到的是「这个患者的文本长什么样」,而不是「这个病长什么样」。验证集 F1 能刷到 0.95,换一批新患者立刻掉到 0.6,血泪经验。
from sklearn.model_selection import train_test_split # 先按患者聚合,保证同一患者的多次就诊只落在一个集合 patients = df[["patient_uid", "diagnosis"]].drop_duplicates("patient_uid") p_tr, p_tmp = train_test_split( patients, test_size=0.3, stratify=patients["diagnosis"], random_state=42) p_va, p_te = train_test_split( p_tmp, test_size=2/3, stratify=p_tmp["diagnosis"], random_state=42) train = df[df.patient_uid.isin(p_tr.patient_uid)].reset_index(drop=True) val = df[df.patient_uid.isin(p_va.patient_uid)].reset_index(drop=True) test = df[df.patient_uid.isin(p_te.patient_uid)].reset_index(drop=True) print(len(train), len(val), len(test))test_size=0.3再从中切2/3,得到 70% / 10% / 20% 的患者级划分。stratify按患者的主标签分层,保证小类在每个集合里都有样本,不然某个只有 60 人的类可能在验证集里一个都没有。random_state固定是为了几个月后回来能复现同一份划分——数据集调整过之后,这个种子一定要重新固定并记下来。
3.4 编码与 Dataset 封装:max_length 和 padding 的取舍
文本进模型之前的最后一站。这里有两个参数值得专门说:max_length和 padding 策略。
import torch from transformers import AutoTokenizer MAX_LEN = 512 tok = AutoTokenizer.from_pretrained( MODEL_DIR, trust_remote_code=True, local_files_only=True) def build_text(r): return f"主诉:{r.chief_complaint}\n检查:{r.report_text}" # 注意:这里不传 padding,训练时用 collator 动态补齐 train_enc = tok(train.apply(build_text, axis=1).tolist(), truncation=True, max_length=MAX_LEN) class MedicalDataset(torch.utils.data.Dataset): def __init__(self, enc, labels): self.enc, self.labels = enc, labels def __len__(self): return len(self.labels) def __getitem__(self, i): item = {k: torch.tensor(v[i]) for k, v in self.enc.items()} item["labels"] = torch.tensor(self.labels[i]) return itempadding=True会把 batch 内所有样本补到固定长度,短文本占的显存全浪费了。正确做法是训练时不 padding,交给DataCollatorWithPadding在组 batch 时补到 batch 内最长,短文本批次能省 30% 以上显存。MAX_LEN=512对主诉加检查报告足够,但要注意截断方向:默认是从右边截,而很多出院小结的结论段落在文末,直接截掉就把最关键的信息丢了。遇到这种情况,我一般会把truncation_side设成"left",或者在build_text里先做一次关键段抽取,把「诊断结论」段提到最前面。
4. 训练实战:DeepSeek 权重加载与 Trainer 的六个关键参数
到了这一步,环境和数据都齐了,剩下的是模型加载和训练参数。这一章的所有参数都按「7B 级别 + 两张 80GB 卡 + 512 长度」这个配置给的,规模变化时按 2.1 的表折算。
4.1 权重离线落地:目录结构与离线开关
内网机器别指望from_pretrained("某某/某某")能联网拉取。权重在能上网的机器上下好,校验完文件完整性,通过内部传输通道传进去,目录摆成这样:
/models/deepseek-med/ ├── config.json ├── tokenizer.json ├── tokenizer_config.json ├── special_tokens_map.json ├── pytorch_model-00001-of-0000N.bin ├── pytorch_model-00002-of-0000N.bin └── pytorch_model.bin.index.jsonindex.json一定不能漏,缺了它from_pretrained只会加载第一个分片并且不报错,表现是 loss 从第一步就异常高,很难查。加载前把离线开关打开:
import os os.environ["HF_HUB_OFFLINE"] = "1" os.environ["TRANSFORMERS_OFFLINE"] = "1"这两个环境变量必须在import transformers之前设,之后设无效。这是 transformer 库的加载顺序决定的,也是很多人明明设了还是卡在联网超时的原因。
4.2 加载模型:num_labels、id2label 与 problem_type
import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification labels = sorted(train["diagnosis"].unique()) lab2id = {l: i for i, l in enumerate(labels)} tokenizer = AutoTokenizer.from_pretrained( MODEL_DIR, trust_remote_code=True, local_files_only=True) model = AutoModelForSequenceClassification.from_pretrained( MODEL_DIR, num_labels=len(labels), # 必须和标签体系一致 id2label={i: l for l, i in lab2id.items()}, # 不填,混淆矩阵全是数字 label2id=lab2id, problem_type="single_label_classification", trust_remote_code=True, local_files_only=True, torch_dtype=torch.bfloat16, # 卡支持 bf16 才开 ) model.config.use_cache = False # 训练时必须关三个参数各解决一个问题。num_labels决定了分类头的输出维度,写错了会在训练第一步就报维度不匹配,这个还算好查。id2label不填不会有任何报错,但后面画混淆矩阵时轴全是 0 到 N 的数字,核对错误样本时要来回翻映射表,非常影响排查效率。problem_type影响 loss 的选择,多标签任务写成single_label_classification会让 softmax 把多个正确标签互相压制。torch_dtype=torch.bfloat16需要加速卡支持,A100 级别以上没问题,老一点的卡开了会直接报不支持的操作。
还有一点要清楚:用AutoModelForSequenceClassification加载生成式权重,原来的语言模型头会被丢掉,换成一个随机初始化的线性分类层,所以第一轮 loss 偏高是正常的,不是权重加载失败。
4.3 TrainingArguments:真正影响结果的几个参数
from transformers import TrainingArguments, Trainer, DataCollatorWithPadding args = TrainingArguments( output_dir="./ckpt/med-cls", num_train_epochs=3, per_device_train_batch_size=8, gradient_accumulation_steps=4, # 等效 batch 32 per_device_eval_batch_size=32, learning_rate=1e-5, # 全参微调;只训分类头可以放到 1e-4 warmup_ratio=0.1, weight_decay=0.01, lr_scheduler_type="cosine", bf16=True, logging_steps=20, evaluation_strategy="epoch", save_strategy="epoch", save_total_limit=2, # 单个 ckpt 十几 G,不限制会撑爆盘 load_best_model_at_end=True, metric_for_best_model="macro_f1", greater_is_better=True, report_to="none", )| 参数 | 取值 | 为什么这么定 |
|---|---|---|
| learning_rate | 1e-5 | 7B 全参微调超过 3e-5 常见 loss 起飞、预测塌成单类 |
| gradient_accumulation_steps | 4 | 显存不够时换等效 batch,比降 lr 安全 |
| warmup_ratio | 0.1 | 换成比例后改数据量不用重算步数 |
| save_total_limit | 2 | 磁盘是真的会被 checkpoint 吃掉 |
| metric_for_best_model | macro_f1 | 长尾科室不能只看 accuracy |
| evaluation_strategy | epoch | 验证集小的时候 steps 评估意义不大 |
配套的compute_metrics里,我一般同时报 accuracy、macro-F1 和 weighted-F1。macro 和 weighted 之间的差距就是长尾的差距:如果 accuracy 0.92、weighted 0.91、macro 只有 0.63,说明头部类全对、尾部类几乎全错,这种模型上线是危险的——尾部往往才是需要辅助诊断的疑难病种。
import numpy as np from sklearn.metrics import accuracy_score, f1_score def compute_metrics(eval_pred): logits, labels = eval_pred preds = np.argmax(logits, axis=-1) return { "accuracy": accuracy_score(labels, preds), "macro_f1": f1_score(labels, preds, average="macro"), "weighted_f1": f1_score(labels, preds, average="weighted"), }4.4 显存不够时的三档降级:bf16、梯度累积、LoRA
按顺序降级,不要一上来就上 LoRA。第一档是 bf16 混合精度,显存直接省一半左右,精度损失可以忽略;第二档是梯度累积,把 batch 拆小、累积步数补回来,等效 batch 不变;第三档才上 LoRA,冻结主干只训适配器。
from peft import LoraConfig, get_peft_model, TaskType lora_cfg = LoraConfig( task_type=TaskType.SEQ_CLS, r=8, # 秩越大容量越强,8 对分类任务够用 lora_alpha=16, # 一般取 r 的 2 倍 lora_dropout=0.05, target_modules=["q_proj", "v_proj"], # 只挂在注意力的 q/v 上 bias="none", ) model = get_peft_model(model, lora_cfg) model.print_trainable_parameters() # 可训练参数通常不到 0.5% model.config.use_cache = False model.gradient_checkpointing_enable() # 再省 20% 到 30%,代价是慢一点开梯度检查点时use_cache必须关,否则会报不兼容。LoRA 训完之后有两个选择:把适配器 merge 回主干再统一量化做推理,或者适配器和量化基座分开部署。我一般选前者,因为分开部署意味着推理时要做两次近似,掉点会叠加,踩过一次之后就不想再试第二次了。
5. 常见问题排查:OOM、标签错位与验证集虚高的五个现场
5.1 现象:trainer.train() 第一步就 CUDA OOM,nvidia-smi 却显示显存空着
原因有两层。一是nvidia-smi显示的是显卡驱动视角的占用,PyTorch 的缓存分配器有自己的显存池,上一轮脚本异常退出后池子里的块没有被释放,新进程申请不到连续显存。二是第一个 batch 恰好抽到了超长样本,长度远超平均值,激活值瞬间翻倍。
解决:先确认有没有残留进程,fuser -v /dev/nvidia*能列出占用者,杀掉重新跑;然后在启动脚本里加export PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128,减少显存碎片;最后在数据侧按长度分桶采样(LengthGroupedSampler),让同一个 batch 里的样本长度接近,能明显减少 OOM 概率。别用torch.cuda.empty_cache()当万能药,它只归还显存给驱动,对碎片问题帮助有限。
5.2 现象:loss 卡在 0.69 附近不动,预测全是一个类
原因通常是标签没转成整数 id,Dataset里torch.tensor(self.labels[i])拿到的是字符串,在 collate 阶段被静默转成了 0;或者num_labels按二分类写成了默认值,所有标签被压到两个维度里。
解决:在MedicalDataset.__init__里加一行断言,assert all(isinstance(l, int) and 0 <= l < num_labels),把问题暴露在构造阶段而不是训练阶段;另外先做一次 50 步的小样本过拟合测试,拿 64 条数据训 50 步,loss 应该能压到接近 0,压不下去说明管道有 bug,不要往下走。
5.3 现象:验证集 macro-F1 0.95,换一批新患者数据掉到 0.6
原因就是 3.3 里说的按行随机划分导致的数据泄漏。同一患者的多次就诊被分到训练和验证两侧,模型记住的是患者文本特征,不是疾病特征。这类模型在内部测试集上表现完美,一上新数据就崩,是最容易让人误判「方案可行」的坑。
解决:改成患者级分组划分,并在最终评估时额外做一次「时间外推」测试——用较早时间段的记录训,用较晚时间段的记录测。这个测试的结果通常比随机划分低 5 到 15 个点,但那才是上线后的真实水平。从那以后我每次交评估报告,都会同时给出随机划分和时间外推两组数。
5.4 现象:单条推理结果正常,batch=8 时结果乱跳
原因在 padding。训练时用 collator 动态右侧补齐,推理时手动tokenizer(texts, padding=True)没传attention_mask,或者 tokenizer 的padding_side被改成了 left 但模型的位置编码没适配。
解决:推理也走同一套DataCollatorWithPadding,保证训练和推理的补齐方式完全一致;把tokenizer.padding_side打印出来记进配置,左右不一致是这类问题的头号来源。另外注意truncation=True在推理时同样要带上,超长文本不截断会直接报维度越界。
5.5 现象:tokenizer 加载卡住,日志报连接超时
原因:from_pretrained默认会去远端校验版本和文件列表,内网出不去就一直等到超时。有人只设了local_files_only=True,但没设环境变量,transformers 在更早的阶段就去联网了。
解决:三个开关一起用——HF_HUB_OFFLINE=1、TRANSFORMERS_OFFLINE=1两个环境变量在import transformers之前设,加上加载时的local_files_only=True;同时把tokenizer.json、tokenizer_config.json、special_tokens_map.json三个文件一起拷进模型目录,缺任何一个都会触发远端查找。
6. 上线前最后一步:量化前后的对比与一次小流量灰度
训练跑完、指标好看,离能用还有一段。我习惯在交付前做三件事:看混淆矩阵定位错在哪、试一版量化推理看掉点多少、用「生成草稿」的方式做小流量灰度。
混淆矩阵看的不是「错了多少」,是「错到哪去了」。同样是 0.85 的准确率,如果错误集中在两个临床处置相近的类之间(比如「上呼吸道感染」和「急性咽炎」),业务方是能接受的;如果错误集中在「良性」和「需紧急处置」之间,那这个模型就不能直接给建议,只能做检索辅助。看矩阵的时候顺手把每一类的支持数标上去,支持数低于 50 的类别,指标波动大,别拿它当优化目标。
量化这一步要有对比表,不能只跑通就完事。下面是我在某次实验里记的量级参考,你的数据不同,绝对值会差很多,但相对关系基本成立:
| 方案 | 单卡显存 | 单条推理延迟 | macro-F1 | 备注 |
|---|---|---|---|---|
| bf16 原模型 | 约 16GB | 基准 | 基准 | 精度最好,成本最高 |
| int8 动态量化 | 约 9GB | 0.6 倍 | 掉 0.5 到 1 个点 | 线性层量化,风险低 |
| 4bit + LoRA 合并 | 约 6GB | 0.45 倍 | 掉 1 到 3 个点 | 要用同一批测试集重评 |
掉点的处理有个小技巧:不要笼统地看整体 F1,按类别看。量化后掉点往往集中在尾部类,这时候把尾部类单独抽出来做一次适配器微调,或者对尾部类单独调低判定阈值,能把掉点补回来一半以上。argmax 在很多场景下不是最优决策,尤其是类别不均衡的时候,per-class 阈值比模型本身更能影响最终效果。
灰度我一般从「生成草稿、医生确认」这个位置切入,不让模型直接出新诊断,只做病历摘要和候选诊断的排序推荐,医生的操作路径不变,只是多了一个可点开的候选列表。这个位置容错高,能收集到真实分布下的 bad case,又不会因为一次错判影响诊疗。跑两周,把线上 bad case 按错误类型归一次类,再回过头决定是补数据还是改标签体系。
最后说个习惯:从那以后我每次交付医疗模型,都会强制走一遍「随机划分 + 时间外推 + 量化后重评」这三道评估,缺一道就不签字。指标好看不难,难的是这个数字在真实分布下还站得住。这套流程和脚本我都整理成了一份可直接照着跑的资料,需要的话按文中的目录结构自己搭一遍,比看十篇文章都管用。希望帮到你。
本文还有配套的精品资源,点击获取