ryan 1 ヶ月 前
親
コミット
a8c40129a9

+ 152 - 0
lora/01_clean_medical_dataset.py

@@ -0,0 +1,152 @@
+"""清洗原始数据中的明确非医疗推理题和严重乱码,不覆盖原文件。"""
+
+import json
+import re
+import unicodedata
+from collections import Counter
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent
+INPUT_PATH = ROOT / "medical_o1_sft_mix_Chinese.json"
+OUTPUT_PATH = ROOT / "medical_o1_sft_mix_Chinese_cleaned.json"
+REMOVED_PATH = ROOT / "medical_o1_sft_mix_Chinese_cleaned_removed.json"
+REPORT_PATH = ROOT / "medical_o1_sft_mix_Chinese_cleaned_report.json"
+FIELDS = ("Question", "Complex_CoT", "Response")
+
+# 这些私有区字符的含义可由上下文确定,优先修复而不是删除医学样本。
+CHAR_REPLACEMENTS = {
+    "\ue06b": "牙合",
+    "\ue02b": "+",
+    "\ue02c": "-",
+    "\ue022": "²",
+    "\ue000": "1",
+    "\ue001": "2",
+    "\ue81f": "歪",
+    "\ue13b": "拇趾",
+}
+
+MEDICAL_WORDS = re.compile(
+    r"患者|患儿|疾病|症状|诊断|治疗|检查|药物|医院|临床|血|尿|肝|肾|心|肺|"
+    r"生物等效|受试制剂|参比制剂|药代|AUC|"
+    r"patient|disease|symptom|diagnos|treat|medicine|medical|clinical|blood|liver|"
+    r"kidney|heart|lung|cancer|syndrome|infection|therapy",
+    re.I,
+)
+
+STATISTICS_PATTERNS = [
+    r"random sample", r"sample mean", r"standard deviation", r"probability that",
+    r"normal distribution", r"exponential distribution", r"binomial distribution",
+    r"confidence interval", r"null hypothesis", r"population mean", r"expected value",
+    r"variance of", r"correlation coefficient", r"regression line", r"z[- ]?score",
+    r"随机样本", r"样本均值", r"标准差", r"概率为", r"概率是多少", r"正态分布",
+    r"指数分布", r"二项分布", r"置信区间", r"原假设", r"总体均值", r"数学期望",
+    r"相关系数", r"回归方程",
+]
+
+GEOMETRY_PATTERNS = [
+    r"\bheptagon\b", r"\bpolygon\b", r"\btriangle\b", r"\brectangle\b",
+    r"\bparallelogram\b", r"\bperimeter\b", r"\bvertices\b", r"area of (?:a|the)",
+    r"多边形", r"三角形的面积", r"长方形的面积", r"正方形的面积", r"周长是多少",
+    r"内角和", r"几何图形",
+]
+
+TIME_PATTERNS = [
+    r"what time", r"at what time", r"how many (?:hours|minutes)",
+    r"(?<![A-Za-z])(?:a\.?m\.?|p\.?m\.?)(?![A-Za-z])",
+    r"minutes (?:before|after)", r"hours (?:before|after)", r"arriv(?:e|es|ed).{0,50}leav",
+    r"几点钟", r"星期几", r"钟表", r"时钟", r"出发.{0,40}到达", r"多少分钟后",
+]
+
+
+def normalize_text(text):
+    text = unicodedata.normalize("NFC", text)
+    text = text.replace("正�?", "正常")
+    text = text.replace("\u00a0", " ").replace("\u3000", " ")
+    for old, new in CHAR_REPLACEMENTS.items():
+        text = text.replace(old, new)
+    return text
+
+
+def pattern_count(text, patterns):
+    return sum(bool(re.search(pattern, text, re.I)) for pattern in patterns)
+
+
+def removal_reason(question):
+    medical = bool(MEDICAL_WORDS.search(question))
+    statistics = pattern_count(question, STATISTICS_PATTERNS)
+    geometry = pattern_count(question, GEOMETRY_PATTERNS)
+    time_reasoning = pattern_count(question, TIME_PATTERNS)
+
+    # 只删除无医疗语境的纯计算题,保留医学统计、遗传概率和药代问题。
+    if statistics >= 1 and not medical:
+        return "pure_math_or_statistics"
+    if geometry >= 1 and not medical:
+        return "pure_geometry"
+    if time_reasoning >= 1 and not medical:
+        return "pure_time_reasoning"
+    return None
+
+
+def has_unrecoverable_character(row):
+    for field in FIELDS:
+        for char in row[field]:
+            category = unicodedata.category(char)
+            if char == "�" or category == "Co":
+                return True
+            if category == "Cc" and char not in "\n\r\t":
+                return True
+    return False
+
+
+def main():
+    for path in (OUTPUT_PATH, REMOVED_PATH, REPORT_PATH):
+        if path.exists():
+            raise FileExistsError(f"为避免覆盖已有结果,请先确认并处理:{path}")
+
+    data = json.loads(INPUT_PATH.read_text(encoding="utf-8"))
+    cleaned = []
+    removed = []
+    reasons = Counter()
+    repaired_records = 0
+
+    for original_index, original_row in enumerate(data):
+        row = dict(original_row)
+        changed = False
+        for field in FIELDS:
+            normalized = normalize_text(row[field])
+            changed |= normalized != row[field]
+            row[field] = normalized
+
+        reason = removal_reason(row["Question"])
+        if reason is None and has_unrecoverable_character(row):
+            reason = "unrecoverable_character"
+
+        if reason:
+            removed.append({"original_index": original_index, "reason": reason, **row})
+            reasons[reason] += 1
+        else:
+            cleaned.append(row)
+            repaired_records += int(changed)
+
+    OUTPUT_PATH.write_text(json.dumps(cleaned, ensure_ascii=False, indent=4), encoding="utf-8")
+    REMOVED_PATH.write_text(json.dumps(removed, ensure_ascii=False, indent=4), encoding="utf-8")
+
+    report = {
+        "input_file": INPUT_PATH.name,
+        "output_file": OUTPUT_PATH.name,
+        "removed_file": REMOVED_PATH.name,
+        "original_records": len(data),
+        "cleaned_records": len(cleaned),
+        "removed_records": len(removed),
+        "repaired_records_kept": repaired_records,
+        "removed_by_reason": dict(reasons),
+        "original_file_overwritten": False,
+    }
+    REPORT_PATH.write_text(json.dumps(report, ensure_ascii=False, indent=4), encoding="utf-8")
+
+    print(json.dumps(report, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    main()

+ 70 - 0
lora/02_split_cleaned_medical_dataset.py

@@ -0,0 +1,70 @@
+"""将清洗后的完整数据去重,并按 8:2 切分训练集和验证集。"""
+
+import json
+import random
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent
+SOURCE_FILE = ROOT / "medical_o1_sft_mix_Chinese_cleaned.json"
+OUTPUT_DIR = ROOT / "medical_o1_sft_mix_Chinese_cleaned_dedup_split_80_20"
+RANDOM_SEED = 42
+
+
+def question_key(record):
+    """统一问题首尾及连续空白,用于问题级去重。"""
+    return " ".join(record["Question"].split())
+
+
+def save_json(path, records):
+    path.write_text(
+        json.dumps(records, ensure_ascii=False, indent=4) + "\n",
+        encoding="utf-8",
+    )
+
+
+def main():
+    records = json.loads(SOURCE_FILE.read_text(encoding="utf-8"))
+    required_fields = {"Question", "Complex_CoT", "Response"}
+    if not isinstance(records, list) or any(
+        not isinstance(record, dict) or not required_fields <= record.keys()
+        for record in records
+    ):
+        raise ValueError(f"每条记录必须包含字段:{required_fields}")
+
+    # 同一个规范化问题只保留第一次出现的完整记录。
+    unique = {}
+    for record in records:
+        unique.setdefault(question_key(record), record)
+    unique_records = list(unique.values())
+
+    random.Random(RANDOM_SEED).shuffle(unique_records)
+    train_size = len(unique_records) * 8 // 10
+    train = unique_records[:train_size]
+    validation = unique_records[train_size:]
+
+    train_keys = {question_key(record) for record in train}
+    validation_keys = {question_key(record) for record in validation}
+    if len(train_keys) != len(train) or len(validation_keys) != len(validation):
+        raise RuntimeError("训练集或验证集内部仍然存在重复问题。")
+    if train_keys & validation_keys:
+        raise RuntimeError("训练集与验证集之间仍然存在重复问题。")
+
+    if OUTPUT_DIR.exists() and any(OUTPUT_DIR.iterdir()):
+        raise FileExistsError(f"为避免覆盖已有结果,请先确认目录:{OUTPUT_DIR}")
+    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+    save_json(OUTPUT_DIR / "train.json", train)
+    save_json(OUTPUT_DIR / "validation.json", validation)
+
+    print(f"清洗后原始记录:{len(records)}")
+    print(f"问题去重后记录:{len(unique_records)}")
+    print(f"删除重复记录:{len(records) - len(unique_records)}")
+    print(f"训练集:{len(train)}")
+    print(f"验证集:{len(validation)}")
+    print(f"训练/验证问题交集:{len(train_keys & validation_keys)}")
+    print(f"随机种子:{RANDOM_SEED}")
+    print(f"保存目录:{OUTPUT_DIR}")
+
+
+if __name__ == "__main__":
+    main()

+ 187 - 0
lora/lora_train.py

@@ -0,0 +1,187 @@
+from transformers import (AutoTokenizer,
+                          AutoModelForCausalLM, #因果模型(自回归模型)
+                          DataCollatorForLanguageModeling,#数据整理器, 用于将分词后的输入数据整理成模型训练所需要的格式
+                          Trainer,#训练器
+                          TrainingArguments
+                          )
+from datasets import load_dataset
+
+#设置预训练模型的路径
+model_name="/root/autodl-tmp/code/model"
+#设置微调后模型保存的路径
+finetuned_model_path="/root/autodl-tmp/code/fined"
+
+#加载模型
+# ---------- 第 1 步:配置 4bit 量化 ----------
+model = AutoModelForCausalLM.from_pretrained(
+    model_name,
+    local_files_only = True, #仅从本地文件加载模型
+)
+
+#加载预训练模型的tokenizer
+tokenizer = AutoTokenizer.from_pretrained(model_name)
+def preprocess_function(examples):
+    """
+    标准的 SFT 数据处理流程
+    - 只训练 assistant 的回答部分
+    - 正确处理截断和 EOS token
+    - 保持 chat template 格式
+    """
+    max_length = 1024
+
+    input_ids_list = []
+    labels_list = []
+
+    for question, cot, response in zip(
+        examples["Question"],
+        examples["Complex_CoT"],
+        examples["Response"]
+    ):
+        # 构建对话消息
+        messages = [
+            {
+                "role": "system",
+                "content": "您是一位在临床推理、诊断和治疗计划方面具有高级知识的医学专家。请回答以下医学问题。"
+            },
+            {
+                "role": "user",
+                "content": question
+            },
+            {
+                "role": "assistant",
+                "content": f"<think>\n{cot}\n</think>\n{response}"
+            }
+        ]
+
+        # ============ Step 1: 生成完整对话 ============
+        full_text = tokenizer.apply_chat_template(
+            messages,
+            tokenize=False,
+            add_generation_prompt=False
+        )
+
+        # ============ Step 2: 生成 prompt 部分(不含 assistant 回复)============
+        prompt_messages = messages[:-1]  # 去掉最后的 assistant
+        prompt_text = tokenizer.apply_chat_template(
+            prompt_messages,
+            tokenize=False,
+            add_generation_prompt=True  # 添加 assistant 开始标记
+        )
+
+        # ============ Step 3: Tokenize ============
+        full_ids = tokenizer.encode(full_text, add_special_tokens=False,
+                                    max_length=max_length,padding="max_length", truncation=True)
+        prompt_ids = tokenizer.encode(prompt_text, add_special_tokens=False,
+                                      max_length=max_length,padding="max_length", truncation=True)
+
+        # ============ Step 4: 处理截断(保留 EOS)============
+        # apply_chat_template 已经自动添加了 EOS,所以只需要处理截断
+        if len(full_ids) > max_length:
+            # 截断到 max_length-1,然后手动添加 EOS
+            full_ids = full_ids[:max_length - 1]
+            full_ids.append(tokenizer.eos_token_id)
+        # 如果没有超长,full_ids 已经包含 EOS,不需要额外处理
+
+        # ============ Step 5: 构建 labels(关键!)============
+        # prompt 部分设为 -100(不计算 loss)
+        # assistant 部分保留原值(计算 loss)
+        prompt_len = len(prompt_ids)
+        labels = (
+            [-100] * prompt_len +           # prompt 部分:不训练
+            full_ids[prompt_len:]            # assistant 部分:训练(包含 EOS)
+        )
+
+        # 确保长度一致
+        labels = labels[:len(full_ids)]
+
+        input_ids_list.append(full_ids)
+        labels_list.append(labels)
+
+    return {
+        "input_ids": input_ids_list,
+        "attention_mask": [[1] * len(ids) for ids in input_ids_list],
+        "labels": labels_list
+    }
+
+dataset = load_dataset(
+    "json",
+    data_files={
+        "train": "/root/autodl-tmp/code/data/train.json",
+        "valid": "/root/autodl-tmp/code/data/validation.json"
+    }
+)
+dataset = dataset.map(preprocess_function, batched = True)
+
+#设置微调模型参数
+from peft import LoraConfig,get_peft_model,TaskType
+#Lora配置
+lora_config=LoraConfig(
+    r=8,#低秩矩阵的秩,根据情况调整
+    lora_alpha=16,#用于缩放Lora权重更新的超参数
+    target_modules=['self_attn.q_proj','self_attn.k_proj','self_attn.v_proj','self_attn.o_proj'],
+    lora_dropout=0.05,#dropout
+    bias='none',#微调偏置
+    task_type=TaskType.CAUSAL_LM
+)
+#获取应用了Lora后的微调模型
+model = get_peft_model(model,lora_config)
+model.config.use_cache = False
+#实际上直接计算所有可训练参数就行。
+trainable_params = 0
+all_params = 0
+for _, param in model.named_parameters():
+    num_params = param.numel()
+    all_params += num_params
+    if param.requires_grad:
+        trainable_params += num_params
+print(f"可训练参数量: {trainable_params}")
+print(f"总参数量: {all_params}")
+print(f"可训练参数占比: {100 * trainable_params / all_params:.2f}%")
+
+model.enable_input_require_grads() # 开启梯度检查点时,要执行该方法
+#初始化可视化功能
+#训练参数配置
+training_args = TrainingArguments(
+    output_dir=finetuned_model_path,#输出目录,用于保存模型和日志
+    label_names=["labels"],  # 显式指定标签名称,确保 Trainer 正确识别 labels
+    overwrite_output_dir=True,#覆盖输出目录的现有内容
+    num_train_epochs=20,#训练的总轮次
+    per_device_train_batch_size=6,#每个训练设备上的批次大小
+    per_device_eval_batch_size=6,#每个评估设备上的批次大小
+    save_steps=1,#每隔多少步保存一次模型
+    save_total_limit=5,#保存的模型文件数量限制
+    prediction_loss_only=True,#在评估期间只返回损失
+    report_to="tensorboard",  # 关键:启用 tensorboard 上报
+    logging_dir="./logs",
+    save_strategy="epoch",
+    #梯度累积步数
+    #通过将多个小批量的梯度累积起来,然后一次性更新模型参数,从而在有限的内存资源下实现相当于更大批量的训练效果。
+    #当累积的步数达到预设的 gradient_accumulation_steps 时,使用累积的梯度更新模型参数,并清零累积的梯度。
+    #节省内存
+    #模拟大批量训练
+    #提高训练稳定性
+    gradient_accumulation_steps=16, #梯度累积步数
+    #梯度检查点,用于减少深度学习模型训练过程中内存占用
+    #反向传播需要存储前向传播中每一层的中间激活值(activations),以便计算梯度。
+    # 对于非常深的模型或大批量数据,这些激活值会占用大量内存
+    #Gradient Checkpointing 的核心思想是只存储部分激活值,并在反向传播时重新计算其他激活值,从而减少内存占用。
+    gradient_checkpointing=True,#启用梯度检查点
+)
+
+#创建模型数据整理器
+data_collator = DataCollatorForLanguageModeling(
+    tokenizer=tokenizer,
+    mlm=False #准备数据以进行标准语言模型任务
+)
+
+#创建训练器
+trainer = Trainer(
+    model = model,#要训练的模型
+    args = training_args,#训练的参数
+    data_collator=data_collator, # 用于准备训练数据
+    train_dataset=dataset["train"],#训练的数据集
+    eval_dataset=dataset["valid"] #评估的数据集
+)
+
+#开始训练模型
+trainer.train()

ファイルの差分が大きいため隠しています
+ 18 - 0
lora/train.json


ファイルの差分が大きいため隠しています
+ 78 - 0
lora/validation.json


+ 177 - 0
lora/训练脚本数据模型完整说明.md

@@ -0,0 +1,177 @@
+# DeepSeek-R1医疗大模型训练:数据说明
+
+## 1. 项目训练流程总览
+
+本项目使用`DeepSeek-R1-Distill-Qwen-7B`作为基础模型,通过QLoRA方法进行中文医疗数据监督微调。完整流程如下:
+
+```text
+原始中文混合数据
+medical_o1_sft_mix_Chinese.json
+        ↓
+01_clean_medical_dataset.py
+        ↓
+清洗后的医疗数据
+medical_o1_sft_mix_Chinese_cleaned.json
+        ↓
+02_split_cleaned_medical_dataset.py
+        ↓
+按问题去重、随机打乱、8:2切分
+train.json + validation.json
+        ↓
+qwen.ipynb
+        ↓
+checkpoint + 最佳LoRA权重
+        ↓
+Notebook的save_pretrained_merged或独立merge.py
+        ↓
+完整16bit合并模型
+        ↓
+推理和对照验证
+```
+
+## 2. 当前归档的训练脚本
+
+训练脚本保存在:
+
+```text
+new_0811/train/
+├── 01_clean_medical_dataset.py
+├── 02_split_cleaned_medical_dataset.py
+├── lora_train.py
+└── merge.py
+```
+
+这些文件是项目根目录正式文件的归档副本,原文件没有被覆盖。脚本中的数据和模型相对路径仍按照项目根目录结构编写。
+
+## 3. 数据清洗脚本
+
+### 3.1 文件
+
+```text
+01_clean_medical_dataset.py
+```
+
+项目根目录原文件为:
+
+```text
+clean_medical_dataset.py
+```
+
+### 3.2 输入
+
+```text
+medical_o1_sft_mix_Chinese.json
+```
+
+原始数据共`25,358`条,每条记录主要包括:
+
+```text
+Question       问题
+Complex_CoT    参考推理过程
+Response       参考最终回答
+```
+
+### 3.3 主要功能
+
+清洗脚本主要排除与中文医疗微调目标明显无关的任务,例如:
+
+- 纯数学概率题;
+- 纯统计计算题;
+- 几何题;
+- 时间推理题;
+- 明显非医疗内容;
+- 含严重乱码、异常控制字符或破坏文本结构的内容。
+
+清洗操作不会覆盖原始数据,并会单独记录被移除的数据和清洗统计。
+
+### 3.4 输出
+
+```text
+medical_o1_sft_mix_Chinese_cleaned.json
+medical_o1_sft_mix_Chinese_cleaned_removed.json
+medical_o1_sft_mix_Chinese_cleaned_report.json
+```
+
+其中,清洗后主数据共有`25,072`条。
+
+## 4. 数据去重和切分脚本
+
+### 4.1 文件
+
+```text
+02_split_cleaned_medical_dataset.py
+```
+
+项目根目录原文件为:
+
+```text
+split_cleaned_medical_dataset.py
+```
+
+### 4.2 输入
+
+```text
+medical_o1_sft_mix_Chinese_cleaned.json
+```
+
+### 4.3 主要功能
+
+脚本按照以下顺序处理:
+
+```text
+读取清洗数据
+    ↓
+按完整记录及Question内容去重
+    ↓
+固定随机种子打乱
+    ↓
+按照8:2切分
+    ↓
+检查训练集和验证集问题交集
+    ↓
+保存train.json和validation.json
+```
+
+按问题内容去重非常重要。如果相同Question同时出现在训练集和验证集中,验证损失可能显得很好,但不能反映模型对新问题的泛化能力,这种情况称为数据泄漏。
+
+### 4.4 输出
+
+```text
+medical_o1_sft_mix_Chinese_cleaned_dedup_split_80_20/
+├── train.json
+└── validation.json
+```
+
+数据数量为:
+
+| 数据文件 | 用途 | 数量 |
+|---|---|---:|
+| `train.json` | 更新LoRA参数 | 17,660条 |
+| `validation.json` | 训练期间验证、早停、选择最佳checkpoint | 4,415条 |
+| 合计 | 去重后的可用数据 | 22,075条 |
+
+需要注意:`validation.json`参与了训练期间的模型选择,因此它是验证集,不是完全独立的最终测试集。
+
+### 
+
+
+
+
+
+### 
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません