Skip to content

60. LoRA Fine Tuning Project | LoRA 微调项目

难度: Hard | 环境: CPU-first | 标签: 训练微调, LoRA, 项目评估 | 目标人群: 项目决策练习者

🚀 云端运行环境

本章节的实战代码可以点击以下链接在免费 GPU 算力平台上直接运行:

Open In ColabOpen In Studio (国内推荐:魔搭社区免费实例)


本节导读

本节承接第 13 节的端到端 SFT 小项目,把“训练链路能否跑通”推进为“LoRA 方案是否值得交付”。你需要先固定数据、训练步数和评测口径,分别跑通 baseline 与 LoRA,再记录可训练参数量、显存、训练耗时、验证结果和生成样例。最后根据效果损失是否可接受、资源收益是否真实存在,判断 LoRA 方案应该采用、调整还是放弃。

关键词: LoRA, training, project, profiling, report


前置阅读

导语: 先把 LoRA 机制、有效 batch 口径和端到端训练闭环理顺,再进入这个项目;本节默认你已经知道训练循环怎么跑,重点转向 LoRA 方案是否值得采用。

相关阅读

导语: 做完基础 LoRA 项目后,最自然的下一步是继续比较 LoRA 变体,或回看训练成本是否真的划算。


Step 1: 定义 LoRA 微调目标

先回答一个问题:在尽量少训练参数的前提下,LoRA 能否完成目标任务,并保留可接受的 train / val loss 表现?

  • 固定底座模型、数据集、batch size、seq len、优化器、学习率和训练 step 数。
  • 明确 baseline 是全参数微调、冻结底座不训练,还是已有的普通微调配置。
  • 训练前先做数据审计:样本数、空 response、重复样本、超长样本和长度分布。
  • 抽样核对 input_ids / attention_mask / labels:response 是否进入 loss,padding 是否被 -100 屏蔽。
  • 记录 LoRA 配置:target modules、rank、alpha、dropout、learning rate、micro batch、accum steps 和 scheduler。
  • 统一记录核心指标:可训练参数量、参数占比、step time、peak memory、train loss、val loss。
  • 这节先建立 LoRA 项目交付模板,再把数据、loss、参数、显存、速度、效果和 artifact 收成一份项目汇总。

Step 2: 跑通 baseline 并记录账本

LoRA 的收益必须和稳定 baseline 对比,不能只看 LoRA 自己能不能跑。

  • 先在同一批样本和同一套训练配置下跑通 baseline。
  • 记录 baseline 的可训练参数量、train/val loss、平均 step time 和 peak memory。
  • 确认 baseline loss 能正常下降,再进入 LoRA 对比。
  • 如果 baseline 本身不稳定,后面的 LoRA 结果就没有可解释性。

Step 3: 插入 LoRA 并做同口径对比

把 LoRA adapter 插到 attention projection 或 MLP linear layer 上,只训练低秩旁路。

  • 冻结底座权重,只让 LoRA 的 A / B 矩阵参与训练。
  • 先计算单层 LoRA 参数量,再估算多层插入后的总可训练参数量。
  • 用同样的 batch、输入长度、训练步数和评估方式比较 LoRA 与 baseline。
  • 重点看三个问题:参数量省了多少,显存 / 速度是否改善,train/val loss 是否仍然正常。

Step 4: 输出微调项目结论

最后把 LoRA 和 baseline 放到同一张表里,说明这次微调方案是否值得采用。

  • 输出 baseline vs LoRA 对比表,至少包含 trainable params、param ratio、step time、peak memory、train loss、val loss。
  • 写清楚 LoRA 节省的是训练参数和优化器状态,不等于底座模型权重不存在。
  • 记录本次 target modules、rank、alpha、dropout、学习率、effective batch 和 scheduler,方便后续复现实验。
  • 保存 adapter,并记录 tokenizer、special tokens、merge 检查和最小生成样例检查。
  • 如果效果不足,下一轮优先调整 rank、插层范围、学习率或 gradient accumulation。
  • 最终产物应回答:数据和 loss 是否可信,LoRA 少训练了多少参数,换来了多少显存 / 速度收益,val loss 损失是否还能接受,adapter 是否可以交付。

Step 5: 最小代码模板

上面的 Step 1-4 是完整 LoRA 微调项目流程。下面的代码实现其中最小、可复用的六块:数据审计、loss mask 核对、项目配置、LoRA 参数账本、结果汇总和交付检查。

图解:09-13 如何收束到 LoRA 项目报告

60 不重复实现训练循环,而是把前面几节已经跑通的机制收成一份可复现的项目报告。

text
09 SFT data       input_ids / attention_mask / labels

10 LoRA           target modules / rank / alpha / dropout

11 Scheduler      lr schedule counted by optimizer update

12 Accumulation   micro batch -> effective batch

13 E2E report     initial/final train loss + val loss


60 LoRA project   data audit + loss mask + parameter ledger + artifacts + decision

项目页最小产物:

模块必须记录用途
数据样本数、空 response、重复样本、超长样本证明训练输入可信
Losssupervised tokens、padding supervised tokens证明 loss 口径正确
配置target modules、rank、alpha、dropout、lr、effective batch保证可复现
账本trainable params、param ratio证明 LoRA 是否省参数
训练结果train/val loss、step time、peak memory判断效果和成本
交付adapter、tokenizer、merge check、sanity generation判断是否能交付
决策accept / tune / reject输出项目结论

60 作为 60–65 的统一模板,最终报告外层使用 fine-tuning-project/v1config / baseline / candidates / quality / resources / artifacts / decision / environment。后续 62–65 只替换项目特有指标,不改变这组公共区域。

图解:微调项目 v2 的交付链路

text
training data ──► data audit ──► loss mask check ──► baseline run


LoRA config ──► adapter training ──► metric comparison ──► artifact check ──► final decision
python
import math
python

# TODO: 完成 LoRA 项目的 5 个核心判断:数据审计、loss 核对、项目汇总、交付检查和最终决策
# 目标:从 09-13 的训练闭环收束到 baseline vs LoRA 项目交付报告

def audit_sft_examples(examples, max_total_chars):
    """审计 SFT 样本,输出训练前最小数据可信度摘要。"""
    # ==========================================
    # TODO 1: 审计 SFT 样本
    # 提示:检查样本数、空 response、重复 prompt/response 和超长样本。
    # ==========================================
    # total_samples = ???
    # empty_response_count = ???
    # duplicate_count = ???
    # over_length_count = ???
    # avg_total_chars = ???
    return {
        'total_samples': total_samples,
        'empty_response_count': empty_response_count,
        'duplicate_count': duplicate_count,
        'over_length_count': over_length_count,
        'avg_total_chars': round(avg_total_chars, 2),
    }

def loss_mask_report(attention_mask, labels, ignore_index=-100):
    """汇总真正参与监督损失的 token 口径。"""
    # ==========================================
    # TODO 2: 核对 loss mask
    # 提示:labels != -100 的 token 会参与 loss;attention_mask == 0 的 padding 不应参与 loss。
    # ==========================================
    # total_tokens = ???
    # non_padding_tokens = ???
    # supervised_tokens = ???
    # padding_supervised_tokens = ???
    # supervised_ratio = ???
    return {
        'total_tokens': total_tokens,
        'non_padding_tokens': non_padding_tokens,
        'supervised_tokens': supervised_tokens,
        'padding_supervised_tokens': padding_supervised_tokens,
        'supervised_ratio': round(supervised_ratio, 4),
    }

# 给定实现:汇总 LoRA 项目配置
def build_lora_project_config(
    base_model,
    target_modules,
    rank,
    alpha,
    dropout,
    learning_rate,
    micro_batch_size,
    accum_steps,
    scheduler,
):
    """打包一次 LoRA 训练的最小复现实验配置。"""
    effective_batch_size = micro_batch_size * accum_steps
    return {
        'base_model': base_model,
        'target_modules': target_modules,
        'rank': rank,
        'alpha': alpha,
        'dropout': dropout,
        'learning_rate': learning_rate,
        'micro_batch_size': micro_batch_size,
        'accum_steps': accum_steps,
        'effective_batch_size': effective_batch_size,
        'scheduler': scheduler,
    }

# 给定实现:计算单层 LoRA 的可训练参数量
def lora_trainable_params(in_dim, out_dim, rank):
    """估算单层 LoRA 需要训练的参数量。"""
    trainable_params = rank * (in_dim + out_dim)
    return trainable_params

# 给定实现:计算完整线性层的参数量
def full_linear_params(in_dim, out_dim):
    """计算对应完整线性层的参数量。"""
    total_params = in_dim * out_dim
    return total_params

# 给定实现:计算 LoRA 参数占比
def lora_param_ratio(in_dim, out_dim, rank):
    """计算 LoRA 可训练参数占完整层参数的比例。"""
    trainable = lora_trainable_params(in_dim, out_dim, rank)
    total = full_linear_params(in_dim, out_dim)
    ratio = trainable / total
    return ratio

def summarize_lora_project(baseline_metrics, lora_metrics):
    """把 baseline 与 LoRA 指标收束成项目对比摘要。"""
    # ==========================================
    # TODO 3: 汇总 baseline 和 LoRA 的项目指标
    # 提示:这里重点只补 5 个项目判断量,资源类 delta = baseline - lora;loss delta = lora - baseline。
    # ==========================================
    # param_reduction = 1.0 - ??? / ???
    # memory_delta = ??? - ???
    # time_delta = ??? - ???
    # train_loss_delta = ??? - ???
    # val_loss_delta = ??? - ???
    return {
        'param_reduction': round(param_reduction, 4),
        'peak_mem_delta_mb': round(memory_delta, 2),
        'step_time_delta_ms': round(time_delta, 2),
        'final_train_loss_delta': round(train_loss_delta, 4),
        'final_val_loss_delta': round(val_loss_delta, 4),
    }

# 给定实现:记录 adapter 交付物
def build_adapter_artifact_record(adapter_path, tokenizer_path, merge_checked, sanity_generation_checked):
    """记录 adapter 交付所需的最小产物信息。"""
    return {
        'adapter_path': adapter_path,
        'tokenizer_path': tokenizer_path,
        'merge_checked': merge_checked,
        'sanity_generation_checked': sanity_generation_checked,
    }

# 给定实现:组装 60 项目的统一结果报告
def build_lora_project_report(config, baseline, candidates, quality, resources, artifacts, decision, environment=None):
    """组装 fine-tuning-project/v1 的公共报告外壳。"""
    return {
        'schema_version': 'fine-tuning-project/v1',
        'project': '60_lora_fine_tuning',
        'stage': 'project_decision',
        'config': config,
        'baseline': baseline,
        'candidates': candidates,
        'quality': quality,
        'resources': resources,
        'artifacts': artifacts,
        'decision': decision,
        'environment': environment or {},
    }

def check_lora_project_readiness(data_audit, mask_report, artifact_record):
    """检查数据、loss 口径和交付产物是否达到上线前闸门。"""
    # ==========================================
    # TODO 4: 检查项目是否可以交付
    # 提示:这里只补关键闸门条件,把 issue 名称按下面给定字符串挂上去即可。
    # ==========================================
    issues = []
    # 这里只做训练前闸门判断,不在这里做最终 accept / reject。
    # if data_audit['empty_response_count'] > ???:
    #     issues.append('empty_response')
    # if data_audit['duplicate_count'] > ???:
    #     issues.append('duplicate_examples')
    # if mask_report['padding_supervised_tokens'] > ???:
    #     issues.append('padding_supervised')
    # if mask_report['supervised_tokens'] == ???:
    #     issues.append('no_supervised_tokens')
    # if not artifact_record['merge_checked']:
    #     issues.append('merge_not_checked')
    # if not artifact_record['sanity_generation_checked']:
    #     issues.append('sanity_generation_not_checked')
    return {'ready': len(issues) == 0, 'issues': issues}

def recommend_lora_decision(summary, readiness, min_param_reduction=0.5, max_val_loss_delta=0.03, min_peak_mem_delta_mb=128.0, min_step_time_delta_ms=-3.0):
    """根据项目摘要输出 accept / tune / reject 结论。"""
    # ==========================================
    # TODO 5: 根据项目汇总和交付检查给出采用建议
    # 规则:
    # - 数据、loss 或 artifact 未准备好:tune
    # - 参数节省达标、val loss 损失可接受,且显存收益足够或速度没有明显恶化:accept
    # - 参数节省达标、val loss 可接受,但显存收益偏弱且速度变慢:tune
    # - 参数节省达标但 val loss 损失偏大:tune
    # - 参数节省不达标:reject
    # ==========================================
    # 资源判断不是只看省了多少参数,还要看显存收益和速度代价是否值得。
    # memory_gain_ok = summary['peak_mem_delta_mb'] >= ???
    # speed_not_too_bad = summary['step_time_delta_ms'] >= ???
    # if ???:
    #     decision = ???
    #     reason = ???
    # elif ???:
    #     decision = ???
    #     reason = ???
    # elif ??? and not (memory_gain_ok or speed_not_too_bad):
    #     decision = ???
    #     reason = ???
    # elif ???:
    #     decision = ???
    #     reason = ???
    # else:
    #     decision = ???
    #     reason = ???
    return {'decision': decision, 'reason': reason}
python
# 测试你的实现
def test_lora_project_template():
    try:
        examples = [
            {'prompt': '问:什么是 LoRA?', 'response': '答:LoRA 是低秩适配方法。'},
            {'prompt': '问:如何检查 loss?', 'response': '答:检查 labels 中参与监督的 token。'},
            {'prompt': '问:什么是 LoRA?', 'response': '答:LoRA 是低秩适配方法。'},
            {'prompt': '问:空回答?', 'response': ''},
        ]
        audit = audit_sft_examples(examples, max_total_chars=30)
        assert audit['total_samples'] == 4, "样本数统计不正确!"
        assert audit['empty_response_count'] == 1, "空 response 统计不正确!"
        assert audit['duplicate_count'] == 1, "重复样本统计不正确!"
        assert audit['over_length_count'] == 1, "超长样本统计不正确!"

        mask = [[1, 1, 1, 0], [1, 1, 0, 0]]
        labels = [[-100, 7, 8, -100], [-100, 9, -100, 3]]
        report = loss_mask_report(mask, labels)
        assert report['total_tokens'] == 8, "total_tokens 统计不正确!"
        assert report['non_padding_tokens'] == 5, "non_padding_tokens 统计不正确!"
        assert report['supervised_tokens'] == 4, "supervised_tokens 统计不正确!"
        assert report['padding_supervised_tokens'] == 1, "padding_supervised_tokens 统计不正确!"
        assert report['supervised_ratio'] == 0.8, "supervised_ratio 计算不正确!"

        config = build_lora_project_config(
            base_model='tiny-llama',
            target_modules=['q_proj', 'v_proj'],
            rank=8,
            alpha=16,
            dropout=0.05,
            learning_rate=2e-4,
            micro_batch_size=2,
            accum_steps=4,
            scheduler='wsd-cosine',
        )
        assert config['effective_batch_size'] == 8, "effective_batch_size 计算不正确!"
        assert config['target_modules'] == ['q_proj', 'v_proj'], "target_modules 应保留原始配置!"

        trainable = lora_trainable_params(8, 8, 2)
        total = full_linear_params(8, 8)
        ratio = lora_param_ratio(8, 8, 2)

        assert trainable == 32, "LoRA 可训练参数量计算不正确!"
        assert total == 64, "完整线性层参数量计算不正确!"
        assert abs(ratio - 0.5) < 1e-12, "LoRA 参数占比计算不正确!"

        baseline = {
            'trainable_params': 1000,
            'step_time_ms': 20.0,
            'peak_mem_mb': 1024.0,
            'final_train_loss': 0.40,
            'final_val_loss': 0.50,
        }
        lora = {
            'trainable_params': 100,
            'step_time_ms': 22.0,
            'peak_mem_mb': 768.0,
            'final_train_loss': 0.42,
            'final_val_loss': 0.52,
        }
        summary = summarize_lora_project(baseline, lora)
        assert summary['param_reduction'] == 0.9, "param_reduction 计算不正确!"
        assert summary['peak_mem_delta_mb'] == 256.0, "peak_mem_delta_mb 计算不正确!"
        assert summary['step_time_delta_ms'] == -2.0, "step_time_delta_ms 计算不正确!"
        assert summary['final_train_loss_delta'] == 0.02, "final_train_loss_delta 计算不正确!"
        assert summary['final_val_loss_delta'] == 0.02, "final_val_loss_delta 计算不正确!"

        artifact = build_adapter_artifact_record(
            adapter_path='outputs/lora-adapter',
            tokenizer_path='outputs/tokenizer',
            merge_checked=True,
            sanity_generation_checked=True,
        )
        project_report = build_lora_project_report(
            config={'model': 'tiny-llama', 'dtype': 'bf16', 'seed': 42},
            baseline=baseline,
            candidates=[{'name': 'lora', **lora}],
            quality={'train_loss': 0.42, 'val_loss': 0.52, 'task_metrics': {}},
            resources={'trainable_params': 100, 'peak_memory_mb': 768.0, 'step_time_ms': 22.0},
            artifacts={'adapter': artifact},
            decision={'decision': 'accept', 'reason': 'test'},
        )
        for section in ('config', 'baseline', 'candidates', 'quality', 'resources', 'artifacts', 'decision', 'environment'):
            assert section in project_report, f'报告缺少 {section} 区域!'
        clean_audit = {'total_samples': 2, 'empty_response_count': 0, 'duplicate_count': 0, 'over_length_count': 0, 'avg_total_chars': 12.0}
        clean_report = {'total_tokens': 8, 'non_padding_tokens': 5, 'supervised_tokens': 3, 'padding_supervised_tokens': 0, 'supervised_ratio': 0.6}
        readiness = check_lora_project_readiness(clean_audit, clean_report, artifact)
        assert readiness['ready'] is True, "干净项目应允许交付!"

        dirty_readiness = check_lora_project_readiness(audit, report, artifact)
        assert dirty_readiness['ready'] is False, "存在数据或 mask 问题时不能交付!"
        assert 'empty_response' in dirty_readiness['issues'], "应报告空 response 问题!"
        assert 'padding_supervised' in dirty_readiness['issues'], "应报告 padding 参与 loss 问题!"

        decision = recommend_lora_decision(summary, readiness, min_param_reduction=0.5, max_val_loss_delta=0.03, min_peak_mem_delta_mb=128.0, min_step_time_delta_ms=-3.0)
        assert decision['decision'] == 'accept', "LoRA 决策应为 accept!"

        assert recommend_lora_decision(summary, dirty_readiness)['decision'] == 'tune', "交付检查未通过时应建议 tune!"

        worse_summary = dict(summary)
        worse_summary['final_val_loss_delta'] = 0.08
        assert recommend_lora_decision(worse_summary, readiness)['decision'] == 'tune', "val loss 损失过大时应建议 tune!"

        weak_summary = dict(summary)
        weak_summary['param_reduction'] = 0.2
        assert recommend_lora_decision(weak_summary, readiness)['decision'] == 'reject', "参数节省不足时应建议 reject!"

        tradeoff_summary = dict(summary)
        tradeoff_summary['peak_mem_delta_mb'] = 32.0
        tradeoff_summary['step_time_delta_ms'] = -6.0
        assert recommend_lora_decision(tradeoff_summary, readiness)['decision'] == 'tune', "显存收益偏弱且速度恶化时应建议 tune!"

        print("✅ LoRA 项目数据审计、loss 核对、账本、交付检查和决策代码通过基础校验。")

    except NotImplementedError:
        print("请先完成 TODO 代码!")
        raise
    except (AttributeError, NameError, TypeError, ValueError, AssertionError, RuntimeError) as e:
        if isinstance(e, AttributeError):
            print("代码未完成,无法找到必要的属性")
        elif isinstance(e, NameError):
            print("代码可能未完成,导致了变量未定义")
        elif isinstance(e, TypeError):
            print("代码可能未完成,导致了操作错误")
        elif isinstance(e, ValueError):
            print("代码可能未完成,导致了数值错误")
        elif isinstance(e, AssertionError):
            print(f"❌ 测试失败: {e}")
        elif isinstance(e, RuntimeError):
            print("代码可能未完成,导致了运行时错误")
        else:
            print("代码可能未完成,导致了断言失败")
        raise NotImplementedError("请先完成 TODO 代码!") from e
    except Exception as e:
        print(f"❌ 发生未知异常: {e}")
        raise


test_lora_project_template()

🛑 STOP HERE 🛑

参考代码与解析

代码

python

# TODO 1: 审计 SFT 样本
def audit_sft_examples(examples, max_total_chars):
    seen = set()
    total_chars = 0
    empty_response_count = 0
    duplicate_count = 0
    over_length_count = 0

    for example in examples:
        prompt = example.get('prompt', '')
        response = example.get('response', '')
        pair = (prompt, response)
        total = len(prompt) + len(response)

        total_chars += total
        if not response.strip():
            empty_response_count += 1
        if pair in seen:
            duplicate_count += 1
        else:
            seen.add(pair)
        if total > max_total_chars:
            over_length_count += 1

    total_samples = len(examples)
    avg_total_chars = total_chars / total_samples if total_samples else 0.0
    return {
        'total_samples': total_samples,
        'empty_response_count': empty_response_count,
        'duplicate_count': duplicate_count,
        'over_length_count': over_length_count,
        'avg_total_chars': round(avg_total_chars, 2),
    }

# TODO 2: 核对 loss mask
def loss_mask_report(attention_mask, labels, ignore_index=-100):
    mask_flat = [value for row in attention_mask for value in row]
    labels_flat = [value for row in labels for value in row]

    if len(mask_flat) != len(labels_flat):
        raise ValueError('attention_mask and labels must have the same number of tokens')

    total_tokens = len(labels_flat)
    non_padding_tokens = sum(1 for mask in mask_flat if mask == 1)
    supervised_tokens = sum(1 for label in labels_flat if label != ignore_index)
    padding_supervised_tokens = sum(
        1 for mask, label in zip(mask_flat, labels_flat)
        if mask == 0 and label != ignore_index
    )
    supervised_ratio = supervised_tokens / non_padding_tokens if non_padding_tokens else 0.0
    return {
        'total_tokens': total_tokens,
        'non_padding_tokens': non_padding_tokens,
        'supervised_tokens': supervised_tokens,
        'padding_supervised_tokens': padding_supervised_tokens,
        'supervised_ratio': round(supervised_ratio, 4),
    }

# 给定实现:汇总 LoRA 项目配置
def build_lora_project_config(
    base_model,
    target_modules,
    rank,
    alpha,
    dropout,
    learning_rate,
    micro_batch_size,
    accum_steps,
    scheduler,
):
    effective_batch_size = micro_batch_size * accum_steps
    return {
        'base_model': base_model,
        'target_modules': target_modules,
        'rank': rank,
        'alpha': alpha,
        'dropout': dropout,
        'learning_rate': learning_rate,
        'micro_batch_size': micro_batch_size,
        'accum_steps': accum_steps,
        'effective_batch_size': effective_batch_size,
        'scheduler': scheduler,
    }

# 给定实现:计算单层 LoRA 的可训练参数量
def lora_trainable_params(in_dim, out_dim, rank):
    """Estimate trainable LoRA parameters for a single linear layer."""
    trainable_params = rank * (in_dim + out_dim)
    return trainable_params

# 给定实现:计算完整线性层的参数量
def full_linear_params(in_dim, out_dim):
    total_params = in_dim * out_dim
    return total_params

# 给定实现:计算 LoRA 参数占比
def lora_param_ratio(in_dim, out_dim, rank):
    trainable = lora_trainable_params(in_dim, out_dim, rank)
    total = full_linear_params(in_dim, out_dim)
    ratio = trainable / total
    return ratio

# TODO 3: 汇总 baseline 和 LoRA 项目指标
def summarize_lora_project(baseline_metrics, lora_metrics):
    param_reduction = 1.0 - lora_metrics['trainable_params'] / baseline_metrics['trainable_params']
    memory_delta = baseline_metrics['peak_mem_mb'] - lora_metrics['peak_mem_mb']
    time_delta = baseline_metrics['step_time_ms'] - lora_metrics['step_time_ms']
    train_loss_delta = lora_metrics['final_train_loss'] - baseline_metrics['final_train_loss']
    val_loss_delta = lora_metrics['final_val_loss'] - baseline_metrics['final_val_loss']
    return {
        'param_reduction': round(param_reduction, 4),
        'peak_mem_delta_mb': round(memory_delta, 2),
        'step_time_delta_ms': round(time_delta, 2),
        'final_train_loss_delta': round(train_loss_delta, 4),
        'final_val_loss_delta': round(val_loss_delta, 4),
    }

# 给定实现:记录 adapter 交付物
def build_adapter_artifact_record(adapter_path, tokenizer_path, merge_checked, sanity_generation_checked):
    return {
        'adapter_path': adapter_path,
        'tokenizer_path': tokenizer_path,
        'merge_checked': merge_checked,
        'sanity_generation_checked': sanity_generation_checked,
    }

# 给定实现:组装 60 项目的统一结果报告
def build_lora_project_report(config, baseline, candidates, quality, resources, artifacts, decision, environment=None):
    return {
        'schema_version': 'fine-tuning-project/v1',
        'project': '60_lora_fine_tuning',
        'stage': 'project_decision',
        'config': config,
        'baseline': baseline,
        'candidates': candidates,
        'quality': quality,
        'resources': resources,
        'artifacts': artifacts,
        'decision': decision,
        'environment': environment or {},
    }

# TODO 4: 检查项目是否可以交付
def check_lora_project_readiness(data_audit, mask_report, artifact_record):
    issues = []
    if data_audit['empty_response_count'] > 0:
        issues.append('empty_response')
    if data_audit['duplicate_count'] > 0:
        issues.append('duplicate_examples')
    if mask_report['padding_supervised_tokens'] > 0:
        issues.append('padding_supervised')
    if mask_report['supervised_tokens'] == 0:
        issues.append('no_supervised_tokens')
    if not artifact_record['merge_checked']:
        issues.append('merge_not_checked')
    if not artifact_record['sanity_generation_checked']:
        issues.append('sanity_generation_not_checked')
    return {'ready': len(issues) == 0, 'issues': issues}

# TODO 5: 根据项目汇总和交付检查给出采用建议
def recommend_lora_decision(summary, readiness, min_param_reduction=0.5, max_val_loss_delta=0.03, min_peak_mem_delta_mb=128.0, min_step_time_delta_ms=-3.0):
    memory_gain_ok = summary['peak_mem_delta_mb'] >= min_peak_mem_delta_mb
    speed_not_too_bad = summary['step_time_delta_ms'] >= min_step_time_delta_ms
    if not readiness['ready']:
        decision = 'tune'
        reason = '数据、loss mask 或 adapter 交付检查未通过,先修复项目可信度问题。'
    elif summary['param_reduction'] < min_param_reduction:
        decision = 'reject'
        reason = '参数节省不足,LoRA 没有带来足够训练成本收益。'
    elif summary['final_val_loss_delta'] > max_val_loss_delta:
        decision = 'tune'
        reason = '参数节省达标,但验证集 loss 损失偏大,优先调 rank、target modules 或学习率。'
    elif not (memory_gain_ok or speed_not_too_bad):
        decision = 'tune'
        reason = '参数节省和验证损失可接受,但显存收益偏弱且速度恶化,优先继续调 rank、插层范围或 batch 配置。'
    else:
        decision = 'accept'
        reason = '参数节省达标,验证集损失可接受,交付检查通过,可以保留当前 LoRA 配置。'
    return {'decision': decision, 'reason': reason}

examples = [
    {'prompt': '问:什么是 LoRA?', 'response': '答:LoRA 是低秩适配方法。'},
    {'prompt': '问:如何检查 loss?', 'response': '答:检查 labels 中参与监督的 token。'},
]
audit = audit_sft_examples(examples, max_total_chars=64)
print(audit)

mask_report = loss_mask_report(
    attention_mask=[[1, 1, 1, 0]],
    labels=[[-100, 7, 8, -100]],
)
print(mask_report)

config = build_lora_project_config(
    base_model='tiny-llama',
    target_modules=['q_proj', 'v_proj'],
    rank=8,
    alpha=16,
    dropout=0.05,
    learning_rate=2e-4,
    micro_batch_size=2,
    accum_steps=4,
    scheduler='wsd-cosine',
)
print(config)

for hidden_size, rank in [(4096, 8), (4096, 16), (8192, 16)]:
    trainable = lora_trainable_params(hidden_size, hidden_size, rank)
    total = full_linear_params(hidden_size, hidden_size)
    ratio = lora_param_ratio(hidden_size, hidden_size, rank)
    print(f"hidden={hidden_size}, rank={rank} -> trainable={trainable:,}, full={total:,}, ratio={ratio:.4%}")

baseline = {'trainable_params': 1000, 'step_time_ms': 20.0, 'peak_mem_mb': 1024.0, 'final_train_loss': 0.40, 'final_val_loss': 0.50}
lora = {'trainable_params': 100, 'step_time_ms': 22.0, 'peak_mem_mb': 768.0, 'final_train_loss': 0.42, 'final_val_loss': 0.52}
summary = summarize_lora_project(baseline, lora)
artifact = build_adapter_artifact_record('outputs/lora-adapter', 'outputs/tokenizer', True, True)
readiness = check_lora_project_readiness(audit, mask_report, artifact)
project_report = build_lora_project_report(
    config={'model': 'tiny-llama', 'dtype': 'bf16', 'seed': 42},
    baseline=baseline,
    candidates=[{'name': 'lora', **lora}],
    quality={'train_loss': lora['final_train_loss'], 'val_loss': lora['final_val_loss'], 'task_metrics': {}},
    resources={'trainable_params': lora['trainable_params'], 'peak_memory_mb': lora['peak_mem_mb'], 'step_time_ms': lora['step_time_ms']},
    artifacts={'adapter': artifact},
    decision=recommend_lora_decision(summary, readiness),
)
print(summary)
print(readiness)
print(project_report)
print(recommend_lora_decision(summary, readiness))

解析

这一版题目区保留 5 个核心 TODO:数据审计、loss 核对、项目汇总、交付检查和最终决策;其余配置打包、参数公式和 artifact 字段整理改成给定实现,把练习重点收回到项目判断本身。

1. TODO 1: 审计 SFT 样本

  • 实现方式:遍历 prompt / response 样本,统计总样本数、空 response、重复样本、超长样本和平均长度。
  • 关键点:微调前先确认数据可信。空 response 会让样本没有有效监督,重复样本会放大小数据过拟合风险,超长样本会改变截断和显存口径。
  • 项目意义:这一步把第 09 节的数据正确性从单条样本扩展到项目级数据集检查。

2. TODO 2: 核对 loss mask

  • 实现方式:把 attention_masklabels 展平后对齐检查,统计非 padding token、参与监督的 token,以及 padding 中错误参与 loss 的 token。
  • 关键点labels != -100 的 token 会参与 loss;attention_mask == 0 的 padding token 不应该参与 loss。
  • 项目意义:这是 SFT 项目最关键的正确性检查之一。loss 下降不代表训练对了,必须确认监督 token 的位置正确。

给定实现 A:汇总 LoRA 项目配置

  • 实现方式:把 base model、target modules、rank、alpha、dropout、学习率、micro batch、accum steps 和 scheduler 放进同一个配置对象。
  • 关键点effective_batch_size = micro_batch_size * accum_steps,这要和第 12 节的梯度累积口径一致。
  • 项目意义:这部分更偏复现实验的脚手架,因此直接给出实现,不占用核心 TODO 配额。

给定实现 B:计算单层 LoRA 的可训练参数量

  • 实现方式:LoRA 为一个线性层增加两个低秩矩阵,A 的参数量是 rank * in_dimB 的参数量是 rank * out_dim,合起来是 rank * (in_dim + out_dim)
  • 关键点:这里统计的是 LoRA adapter 的可训练参数,不包括冻结的底座权重。
  • 项目意义:这是 LoRA 微调项目的第一张账本,但公式本身偏机械,因此也改为给定实现。

给定实现 C:计算完整线性层的参数量

  • 实现方式:完整线性层的 weight 参数量是 in_dim * out_dim。本节为了突出主线,不额外统计 bias。
  • 关键点:全参线性层是 baseline,用来衡量 LoRA 的参数节省比例。
  • 技术细节:如果真实模型中包含 bias 或多个投影层,需要把这些层逐项累加。

给定实现 D:计算 LoRA 参数占比

  • 实现方式:先分别计算 LoRA 参数量和完整线性层参数量,再用 trainable / total 得到参数占比。
  • 关键点:参数占比越小,说明同一层上需要训练和保存的 adapter 越少。
  • 项目意义:这个比例可以和 step time、peak memory、train/val loss 一起放进项目报告,但不需要读者再为基础公式分散注意力。

3. TODO 3: 汇总 baseline 和 LoRA 项目指标

  • 实现方式:资源类指标使用 baseline - LoRA,正数表示 LoRA 更省或更快;loss 指标使用 LoRA - baseline,正数表示 LoRA 效果更差。
  • 关键点:train loss 和 val loss 要分开看。train loss 接近不代表泛化可接受,最终决策更应该看 val loss delta。
  • 工程判断:如果参数和显存明显下降,但 val loss 损失很小,LoRA 方案通常值得保留;如果 val loss 明显变差,需要继续调整 rank、插层位置或学习率。

给定实现 E:记录 adapter 交付物

  • 实现方式:记录 adapter 路径、tokenizer 路径、merge 检查和最小生成样例检查。
  • 关键点:LoRA 微调的交付物不是一行 loss,而是一组可加载、可复现、能做 sanity check 的 artifact。
  • 项目意义:这一步把训练实验推进到交付边界,但字段整理本身不应挤占核心 TODO。

4. TODO 4: 检查项目是否可以交付

  • 实现方式:把数据审计、loss mask 报告和 artifact 记录合并检查,返回 ready 和问题列表。
  • 关键点:只要存在空 response、padding 参与 loss、无监督 token、merge 未检查或生成样例未检查,就不应该直接把项目判为 accept。
  • 项目意义:这一步让项目报告不只比较指标,也能说明指标是否可信。

5. TODO 5: 输出采用建议

  • accept:交付检查通过,参数节省达标,val loss 损失在阈值内。
  • tune:交付检查未通过,或参数节省达标但 val loss 损失偏大,或显存收益偏弱且速度恶化。
  • reject:交付检查通过,但参数节省不足,LoRA 没有带来足够训练成本收益。
  • 项目意义:决策不再只看 LoRA 参数比例,而是同时看数据可信度、loss 口径、artifact 交付、资源收益和效果损失。

Step 6(可选):真实模型 LoRA 验证

这一步对应 66 节的真实 backend 分支,但验证对象不同:66 验证推理服务,60 验证真实模型、tokenizer、LoRA adapter、训练 step 和 artifact 保存链路。默认关闭,不影响 CPU-first 练习。

真实运行只完成小规模 smoke test,不等于完整微调效果结论;要形成正式结论,还需要固定数据集、训练步数、验证集和同口径 baseline。Colab / ModelScope 运行前请先阅读训练微调项目验证清单

数据可以从 inline、Hugging Face、ModelScope 或本地 JSON/JSONL 读取。远程数据集支持 instruction / input / outputprompt / response 字段;真实项目建议使用固定版本、固定抽样数量,并把数据集 ID、来源和审计结果写入报告。

python
# 只需要修改这一格;默认关闭真实模型下载和 GPU 训练
RUN_REAL_TRAINING = False
REAL_MODEL_SOURCE = 'huggingface'  # 模型来源:auto / modelscope / huggingface / local。
REAL_MODEL_ID = 'Qwen/Qwen2.5-0.5B-Instruct'  # 基座模型;小模型便于 Colab 和约 12 GB 显存设备运行。
# 缓存、结果和本地数据路径均由 Notebook 根据仓库根目录自动推导,不需要手填路径。
REAL_DTYPE = 'auto'  # auto 优先 BF16(硬件支持时),否则回退 FP16;也可写 bfloat16 / float16。
REAL_MAX_SEQ_LEN = 256  # 每条样本最大 token 长度;影响截断、显存和 step time。
REAL_STEPS = 3  # Step 6 smoke test 的更新步数;Step 7 使用 MATCHED_STEPS。
REAL_LR = 2e-4
AUTO_INSTALL_REAL_DEPS = True  # 自动安装 transformers / peft / datasets 等当前内核依赖。
REAL_DATA_SOURCE = 'huggingface'  # 数据来源:inline 仅适合 smoke test;正式比较用 huggingface / modelscope / local。
REAL_DATASET_ID = 'tatsu-lab/alpaca'
REAL_DATA_FILE = None  # None 时自动搜索 benchmarks/data/ 和 data/
REAL_MAX_SAMPLES = 32  # 最多读取样本数;baseline 和 LoRA 必须使用同一批数据。
REAL_SEED = 2024  # 模型初始化、训练随机性的种子;不同实验可改变它。
SPLIT_SEED = 42  # 训练/验证集划分种子;跨实验固定,避免验证集随 REAL_SEED 改变。
RUN_REAL_MATCHED = True  # Step 7:需要正式采集时再改为 True。
MATCHED_BATCH_SIZE = 1  # 每次送入 GPU 的 micro-batch,不是有效 batch 总大小。
MATCHED_VAL_RATIO = 0.2  # 固定留作验证的数据比例。
MATCHED_STEPS = 20  # baseline 和 LoRA 必须使用相同更新步数。
python
# 可选:打开真实模型验证后,自动为当前 Notebook 内核补齐依赖
# 必须先运行上一格配置,再运行这一格。
if (RUN_REAL_TRAINING or RUN_REAL_MATCHED) and AUTO_INSTALL_REAL_DEPS:
    import subprocess
    import sys
    packages = ['transformers', 'peft', 'accelerate', 'datasets', 'httpx[socks]']
    if REAL_MODEL_SOURCE == 'modelscope' or REAL_DATA_SOURCE == 'modelscope':
        packages.append('modelscope')
    subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-U', *packages])
    print('真实模型和数据集依赖安装完成,请继续运行后续单元。')
elif not (RUN_REAL_TRAINING or RUN_REAL_MATCHED):
    print('跳过依赖安装:真实模型验证未开启。')
python
if RUN_REAL_TRAINING:
    import random
    def _real_audit(records, max_total_chars):
        pairs = [(item.get('prompt', ''), item.get('response', '')) for item in records]
        return {'total_samples': len(records), 'empty_response_count': sum(not response.strip() for _, response in pairs), 'duplicate_count': len(pairs) - len(set(pairs)), 'over_length_count': sum(len(prompt) + len(response) > max_total_chars for prompt, response in pairs), 'avg_total_chars': round(sum(len(prompt) + len(response) for prompt, response in pairs) / len(records), 2) if records else 0.0}
    def _real_report(**sections):
        return {'schema_version': 'fine-tuning-project/v1', 'project': '60_lora_fine_tuning', 'stage': 'project_decision', **sections}
    import json
    import os
    import sys
    import time
    from pathlib import Path

    import torch
    random.seed(REAL_SEED)
    torch.manual_seed(REAL_SEED)
    torch.cuda.manual_seed_all(REAL_SEED)
    from transformers import AutoModelForCausalLM, AutoTokenizer
    try:
        from peft import LoraConfig, get_peft_model
    except ImportError as exc:
        raise RuntimeError('真实 LoRA 验证需要 peft:请先安装 transformers peft accelerate。') from exc

    project_root = next((path for path in [Path.cwd(), *Path.cwd().parents] if (path / 'tools').is_dir()), None)
    if project_root is None:
        raise RuntimeError('未找到项目根目录,请从仓库根目录启动 Notebook。')
    os.chdir(project_root)
    if str(project_root) not in sys.path:
        sys.path.insert(0, str(project_root))
    from tools.model_runtime import resolve_model

    if not torch.cuda.is_available():
        raise RuntimeError('RUN_REAL_TRAINING=True 需要可用 CUDA GPU。')
    device = torch.device('cuda')
    if REAL_DTYPE == 'auto':
        dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
    else:
        dtype = getattr(torch, REAL_DTYPE)
    model_path = resolve_model(REAL_MODEL_ID, REAL_MODEL_SOURCE)
    tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=True)
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token
    model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=dtype)
    model.config.use_cache = False
    model = get_peft_model(model, LoraConfig(
        r=8, lora_alpha=16, lora_dropout=0.05,
        target_modules=['q_proj', 'v_proj'], task_type='CAUSAL_LM',
    ))
    model.to(device).train()
    model.print_trainable_parameters()

    if REAL_DATA_SOURCE == 'inline':
        examples = [
            {'prompt': '用一句话解释 LoRA。', 'response': 'LoRA 是一种低秩参数高效微调方法。'},
            {'prompt': '用一句话解释梯度累积。', 'response': '梯度累积通过多次小批量反向传播模拟更大的 batch。'},
            {'prompt': '用一句话解释验证集。', 'response': '验证集用于检查模型对未参与训练样本的泛化表现。'},
            {'prompt': '用一句话解释 adapter。', 'response': 'adapter 是挂载在基座模型上的可训练增量参数。'},
        ]
    elif REAL_DATA_SOURCE == 'local':
        search_roots = [project_root / 'benchmarks' / 'data', project_root / 'data']
        candidates = [path for root in search_roots if root.exists() for path in root.glob('*') if path.suffix.lower() in {'.json', '.jsonl'}]
        data_path = Path(REAL_DATA_FILE) if REAL_DATA_FILE else (candidates[0] if candidates else None)
        if data_path is None:
            raise FileNotFoundError('未在 benchmarks/data 或 data 中找到 JSON/JSONL 数据文件')
        if not data_path.exists():
            raise FileNotFoundError(f'本地数据文件不存在:{data_path}')
        if data_path.suffix.lower() == '.jsonl':
            records = [json.loads(line) for line in data_path.read_text(encoding='utf-8').splitlines() if line.strip()]
        else:
            records = json.loads(data_path.read_text(encoding='utf-8'))
    elif REAL_DATA_SOURCE == 'huggingface':
        from datasets import load_dataset
        records = load_dataset(REAL_DATASET_ID, split='train')
    elif REAL_DATA_SOURCE == 'modelscope':
        from modelscope.msdatasets import MsDataset
        records = MsDataset.load(REAL_DATASET_ID, split='train')
        if hasattr(records, 'to_hf_dataset'):
            records = records.to_hf_dataset()
    else:
        raise ValueError('REAL_DATA_SOURCE 必须是 inline / huggingface / modelscope / local')
    if REAL_DATA_SOURCE != 'inline':
        examples = []
        for record in list(records)[:REAL_MAX_SAMPLES]:
            if 'prompt' in record and 'response' in record:
                prompt, response = record['prompt'], record['response']
            else:
                prompt = str(record.get('instruction', '')) + str(record.get('input', ''))
                response = record.get('output', record.get('response', ''))
            if prompt and response:
                examples.append({'prompt': str(prompt), 'response': str(response)})
        if not examples:
            raise ValueError('数据集中没有识别到 prompt/response 或 instruction/input/output 字段')
    examples = examples[:REAL_MAX_SAMPLES]
    data_audit = _real_audit(examples, max_total_chars=REAL_MAX_SEQ_LEN * 4)
    texts = [item['prompt'] + '\n' + item['response'] for item in examples]
    batch = tokenizer(texts, return_tensors='pt', padding=True, truncation=True, max_length=REAL_MAX_SEQ_LEN)
    input_ids = batch['input_ids'].to(device)
    attention_mask = batch['attention_mask'].to(device)
    labels = input_ids.masked_fill(attention_mask == 0, -100)
    optimizer = torch.optim.AdamW((p for p in model.parameters() if p.requires_grad), lr=REAL_LR)
    for _ in range(2):
        optimizer.zero_grad(set_to_none=True)
        loss = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels).loss
        loss.backward()
        optimizer.step()
    torch.cuda.synchronize()
    torch.cuda.reset_peak_memory_stats()
    started = time.perf_counter()
    losses = []
    for _ in range(REAL_STEPS):
        optimizer.zero_grad(set_to_none=True)
        loss = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels).loss
        loss.backward()
        optimizer.step()
        losses.append(float(loss.detach().item()))
    torch.cuda.synchronize()
    elapsed = time.perf_counter() - started
    output_dir = project_root / 'benchmarks' / 'results' / '60_real_lora'
    output_dir.mkdir(parents=True, exist_ok=True)
    adapter_dir = output_dir / 'adapter'
    model.save_pretrained(adapter_dir)
    tokenizer.save_pretrained(output_dir / 'tokenizer')
    report_builder = globals().get('build_lora_project_report', _real_report)
    report = report_builder(
        config={'model': REAL_MODEL_ID, 'model_path': model_path, 'dtype': str(dtype), 'batch_size': len(examples), 'seq_len': REAL_MAX_SEQ_LEN, 'steps': REAL_STEPS, 'seed': REAL_SEED},
        baseline={'status': 'not_run', 'reason': 'real smoke test does not run a matched full-parameter baseline'},
        candidates=[{'name': 'real_lora_smoke', 'status': 'ok', 'losses': losses}],
        quality={'train_loss': losses[-1], 'val_loss': None, 'task_metrics': {}, 'data_audit': data_audit, 'quality_status': 'smoke_only'},
        resources={'trainable_params': sum(p.numel() for p in model.parameters() if p.requires_grad), 'peak_memory_mb': round(torch.cuda.max_memory_allocated() / 2**20, 2), 'step_time_ms': round(elapsed / REAL_STEPS * 1000, 2), 'tokens_per_s': round(input_ids.numel() * REAL_STEPS / elapsed, 2)},
        artifacts={'adapter': str(adapter_dir), 'tokenizer': str(output_dir / 'tokenizer'), 'report': str(output_dir / '60_real_lora.json')},
        decision={'decision': 'tune', 'reason': '真实 LoRA smoke test 已完成,但尚未与同口径 baseline 和验证集比较。', 'next_action': 'run_matched_baseline_and_validation'},
        environment={'python': sys.version, 'torch': torch.__version__, 'torch_cuda': torch.version.cuda, 'device': torch.cuda.get_device_name(0)},
    )
    (output_dir / '60_real_lora.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    print(json.dumps(report, ensure_ascii=False, indent=2))
else:
    print('跳过真实模型验证:保持 CPU-first 模式。')

Step 7(可选):自动采集 baseline vs LoRA

Step 6 只验证真实 LoRA 链路;Step 7 才用于正式采集。它会自动固定 train/validation 划分,按 batch 分批,并依次运行 full-parameter baseline 与 LoRA。

默认关闭。打开 RUN_REAL_MATCHED = True 后,不需要复制训练代码或填写路径;结果会保存为 benchmarks/results/60_real_lora/60_real_lora_matched.json

已有三组真实数据:先看表,再决定是否继续实验

下面三组结果来自同一份真实数据和同一套训练配置,只改变模型初始化用的 REAL_SEEDSPLIT_SEED=42 在三组中固定。每一组内部都使用同一数据切分、同一 batch、同一序列长度和同一步数,因此可以比较 baseline 与 LoRA;不同 seed 之间用于观察训练波动,不应当作三次独立数据集实验。

共同实验条件

条件取值说明
基座模型Qwen/Qwen2.5-0.5B-Instruct真实 Hugging Face 模型;三组使用同一缓存快照
数据tatsu-lab/alpaca,32 条prompt / response 规范化;空回答和重复样本均为 0
数据切分val_ratio=0.2baseline 与 LoRA 在每组内共享切分
dtypetorch.bfloat16RTX 5070 Ti Laptop GPU,BF16 可用
micro-batch1不是有效 batch;本实验未使用梯度累积
最大序列长度256影响截断、显存和吞吐
更新步数20baseline 与 LoRA 完全一致
评测validation loss当前还没有 task-level 生成指标,因此结论仍是 tune
环境Python 3.10.20,PyTorch 2.11.0+cu128,CUDA 12.8NVIDIA GeForce RTX 5070 Ti Laptop GPU,约 12 GB 显存

三组 matched 结果

REAL_SEEDSPLIT_SEEDbaseline val lossLoRA val lossbaseline 峰值显存 MBLoRA 峰值显存 MBbaseline step msLoRA step msbaseline token/sLoRA token/s数据审计
42424.31032.02994774.921741.94126.9139.14558.281810.3832 条;超长 3
123424.31032.02634786.981741.94106.0941.87667.821692.2232 条;超长 3
2024424.31032.03074786.981741.94107.6238.62658.311834.6232 条;超长 3
均值424.31032.02894782.961741.94113.5439.88628.141779.07每组超长 3

这三组结果支持一个暂定资源结论:LoRA 的峰值显存约降低 63.6%,step time 约降低 64.9%,token 吞吐约提高 2.83 倍;LoRA validation loss 约为 2.029,低于 matched baseline 的 4.310。但由于样本只有 32 条、每组有 3 条超出字符审计阈值,且还没有生成质量指标,暂不把它写成最终 accept。本轮数据采集先冻结;后续只在需要补生成质量、实际截断统计或压力实验时继续。

over_length_count=3 表示字符长度代理指标超过 REAL_MAX_SEQ_LEN * 4,不等于一定发生 token 截断;后续应把实际 tokenizer 截断数也记录下来。

python
# 先画已有结果和后续实验计划;本单元只读 JSON,不启动模型、不下载数据。
import json
from pathlib import Path

import pandas as pd
from IPython.display import display

RESULT_DIR = Path('benchmarks/results/60_real_lora')
RESULT_FILES = sorted(RESULT_DIR.glob('60_real_lora_matched_seed*.json'))

rows = []
for path in RESULT_FILES:
    report = json.loads(path.read_text(encoding='utf-8'))
    cfg = report['config']
    audit = report['quality']['data_audit']
    baseline = report['baseline']
    # 兼容旧报告:早期版本把 LoRA 放在 candidates[name='lora'] 中。
    lora = report.get('lora')
    if lora is None:
        lora = next(item for item in report.get('candidates', []) if item.get('name') == 'lora')
    rows.append({
        'seed': cfg['seed'],
        'split_seed': cfg.get('split_seed', 'legacy'),
        'samples': audit['total_samples'],
        'val_ratio': cfg['val_ratio'],
        'baseline_val_loss': round(baseline['val_loss'], 4),
        'lora_val_loss': round(lora['val_loss'], 4),
        'baseline_peak_MB': round(baseline['peak_memory_mb'], 2),
        'lora_peak_MB': round(lora['peak_memory_mb'], 2),
        'baseline_step_ms': round(baseline['step_time_ms'], 2),
        'lora_step_ms': round(lora['step_time_ms'], 2),
        'baseline_tok/s': round(baseline['tokens_per_s'], 2),
        'lora_tok/s': round(lora['tokens_per_s'], 2),
        'over_length': audit['over_length_count'],
        'file': path.name,
    })

results_df = pd.DataFrame(rows).sort_values('seed') if rows else pd.DataFrame()
display(results_df)

# 后续实验矩阵:先画计划表,完成每组实验后再把 status 改为 measured。
EXPERIMENT_PLAN = pd.DataFrame([
    {'id': 'S1', 'variable': 'seed', 'value': 42, 'fixed': '真实数据 / batch=1 / seq=256 / steps=20', 'status': 'measured'},
    {'id': 'S2', 'variable': 'seed', 'value': 123, 'fixed': '真实数据 / batch=1 / seq=256 / steps=20', 'status': 'measured'},
    {'id': 'S3', 'variable': 'seed', 'value': 2024, 'fixed': '真实数据 / batch=1 / seq=256 / steps=20', 'status': 'measured'},
    {'id': 'B1', 'variable': 'matched_steps', 'value': 40, 'fixed': '固定 split seed / batch=1 / seq=256', 'status': 'planned'},
    {'id': 'B2', 'variable': 'seq_len', 'value': 512, 'fixed': '固定数据 / seed / batch=1 / steps=20', 'status': 'planned'},
    {'id': 'B3', 'variable': 'batch_size', 'value': 2, 'fixed': '固定数据 / seed / seq=256 / steps=20', 'status': 'planned'},
    {'id': 'B4', 'variable': 'task_metric', 'value': 'generation_eval', 'fixed': '固定 split / prompt / max_new_tokens', 'status': 'planned'},
])
display(EXPERIMENT_PLAN)
print('说明:一次只改变 variable;不要同时改变 seed、数据切分、seq_len、batch 或 steps。')
python
if RUN_REAL_MATCHED:
    import random
    def _matched_audit(records, max_total_chars):
        pairs = [(item.get('prompt', ''), item.get('response', '')) for item in records]
        return {'total_samples': len(records), 'empty_response_count': sum(not response.strip() for _, response in pairs), 'duplicate_count': len(pairs) - len(set(pairs)), 'over_length_count': sum(len(prompt) + len(response) > max_total_chars for prompt, response in pairs), 'avg_total_chars': round(sum(len(prompt) + len(response) for prompt, response in pairs) / len(records), 2) if records else 0.0}
    def _matched_report(**sections):
        return {'schema_version': 'fine-tuning-project/v1', 'project': '60_lora_fine_tuning', 'stage': 'project_decision', **sections}
    import gc
    import json
    import os
    import sys
    import time
    from pathlib import Path

    import torch
    random.seed(REAL_SEED)
    torch.manual_seed(REAL_SEED)
    torch.cuda.manual_seed_all(REAL_SEED)
    from transformers import AutoModelForCausalLM, AutoTokenizer
    from peft import LoraConfig, get_peft_model

    project_root = next((path for path in [Path.cwd(), *Path.cwd().parents] if (path / 'tools').is_dir()), None)
    if project_root is None:
        raise RuntimeError('未找到项目根目录,请从仓库根目录启动 Notebook。')
    os.chdir(project_root)
    if not torch.cuda.is_available():
        raise RuntimeError('RUN_REAL_MATCHED=True 需要可用 CUDA GPU。')
    from tools.model_runtime import resolve_model

    model_path = resolve_model(REAL_MODEL_ID, REAL_MODEL_SOURCE)
    dtype = torch.bfloat16 if REAL_DTYPE == 'auto' and torch.cuda.is_bf16_supported() else torch.float16
    if REAL_DTYPE != 'auto':
        dtype = getattr(torch, REAL_DTYPE)
    tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=True)
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token

    # 优先复用 Step 6 已经加载的数据;否则按同一配置自动加载。
    if 'examples' not in globals() or REAL_DATA_SOURCE != 'inline':
        if REAL_DATA_SOURCE == 'inline':
            records = [
                {'prompt': '用一句话解释 LoRA。', 'response': 'LoRA 是一种低秩参数高效微调方法。'},
                {'prompt': '用一句话解释梯度累积。', 'response': '梯度累积通过多次小批量反向传播模拟更大的 batch。'},
                {'prompt': '用一句话解释验证集。', 'response': '验证集用于检查模型对未参与训练样本的泛化表现。'},
                {'prompt': '用一句话解释 adapter。', 'response': 'adapter 是挂载在基座模型上的可训练增量参数。'},
            ]
        elif REAL_DATA_SOURCE == 'huggingface':
            from datasets import load_dataset
            records = load_dataset(REAL_DATASET_ID, split='train')
        elif REAL_DATA_SOURCE == 'modelscope':
            from modelscope.msdatasets import MsDataset
            records = MsDataset.load(REAL_DATASET_ID, split='train')
            if hasattr(records, 'to_hf_dataset'):
                records = records.to_hf_dataset()
        elif REAL_DATA_SOURCE == 'local':
            search_roots = [project_root / 'benchmarks' / 'data', project_root / 'data']
            data_candidates = [path for root in search_roots if root.exists() for path in root.glob('*') if path.suffix.lower() in {'.json', '.jsonl'}]
            data_path = Path(REAL_DATA_FILE) if REAL_DATA_FILE else (data_candidates[0] if data_candidates else None)
            if data_path is None:
                raise FileNotFoundError('未在 benchmarks/data 或 data 中找到 JSON/JSONL 数据文件')
            records = [json.loads(line) for line in data_path.read_text(encoding='utf-8').splitlines() if line.strip()] if data_path.suffix.lower() == '.jsonl' else json.loads(data_path.read_text(encoding='utf-8'))
        else:
            raise ValueError('REAL_DATA_SOURCE 必须是 inline / huggingface / modelscope / local')
        examples = []
        for record in list(records)[:REAL_MAX_SAMPLES]:
            prompt = record.get('prompt', record.get('instruction', ''))
            if 'prompt' not in record:
                prompt = str(prompt) + str(record.get('input', ''))
            response = record.get('response', record.get('output', ''))
            if prompt and response:
                examples.append({'prompt': str(prompt), 'response': str(response)})
        if not examples:
            raise ValueError('数据集中没有识别到 prompt/response 或 instruction/input/output 字段')
    examples = examples[:REAL_MAX_SAMPLES]
    # 数据划分使用独立且固定的种子;不要用 REAL_SEED,否则质量变化会混入切分变化。
    random.Random(SPLIT_SEED).shuffle(examples)
    split = max(1, int(len(examples) * (1 - MATCHED_VAL_RATIO)))
    train_examples, val_examples = examples[:split], examples[split:] or examples[-1:]

    def encode_records(records):
        texts = [item['prompt'] + '\n' + item['response'] for item in records]
        batch = tokenizer(texts, return_tensors='pt', padding=True, truncation=True, max_length=REAL_MAX_SEQ_LEN)
        batch['labels'] = batch['input_ids'].masked_fill(batch['attention_mask'] == 0, -100)
        return batch

    train_batch = encode_records(train_examples)
    val_batch = encode_records(val_examples)
    device = torch.device('cuda')

    def batches(encoded):
        size = encoded['input_ids'].shape[0]
        for start in range(0, size, MATCHED_BATCH_SIZE):
            yield {key: value[start:start + MATCHED_BATCH_SIZE].to(device) for key, value in encoded.items()}

    def run_candidate(name):
        random.seed(REAL_SEED)
        torch.manual_seed(REAL_SEED)
        torch.cuda.manual_seed_all(REAL_SEED)
        model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=dtype)
        model.config.use_cache = False
        if name == 'lora':
            model = get_peft_model(model, LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05, target_modules=['q_proj', 'v_proj'], task_type='CAUSAL_LM'))
        model.to(device).train()
        trainable_params = sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)
        optimizer = torch.optim.AdamW((parameter for parameter in model.parameters() if parameter.requires_grad), lr=REAL_LR)
        torch.cuda.empty_cache()
        torch.cuda.reset_peak_memory_stats()
        losses = []
        processed_tokens = 0
        started = time.perf_counter()
        train_batches = list(batches(train_batch))
        for step in range(MATCHED_STEPS):
            batch = train_batches[step % len(train_batches)]
            processed_tokens += int(batch['attention_mask'].sum().item())
            optimizer.zero_grad(set_to_none=True)
            loss = model(**batch).loss
            loss.backward()
            optimizer.step()
            losses.append(float(loss.detach().item()))
        torch.cuda.synchronize()
        elapsed = time.perf_counter() - started
        model.eval()
        with torch.no_grad():
            val_losses = [float(model(**batch).loss.item()) for batch in batches(val_batch)]
        peak_memory = round(torch.cuda.max_memory_allocated() / 2**20, 2)
        result = {'name': name, 'status': 'ok', 'train_losses': losses, 'train_loss': losses[-1], 'val_loss': sum(val_losses) / len(val_losses), 'trainable_params': trainable_params, 'peak_memory_mb': peak_memory, 'step_time_ms': round(elapsed / MATCHED_STEPS * 1000, 2), 'tokens_per_s': round(processed_tokens / elapsed, 2)}
        if name == 'lora':
            output_dir = project_root / 'benchmarks' / 'results' / '60_real_lora'
            output_dir.mkdir(parents=True, exist_ok=True)
            model.save_pretrained(output_dir / 'matched_adapter')
        del optimizer, model
        gc.collect()
        torch.cuda.empty_cache()
        return result

    candidates = [run_candidate('baseline'), run_candidate('lora')]
    baseline = candidates[0]
    lora = candidates[1]
    report_builder = globals().get('build_lora_project_report', _matched_report)
    report = report_builder(
        config={'model': REAL_MODEL_ID, 'model_path': model_path, 'dtype': str(dtype), 'batch_size': MATCHED_BATCH_SIZE, 'seq_len': REAL_MAX_SEQ_LEN, 'steps': MATCHED_STEPS, 'val_ratio': MATCHED_VAL_RATIO, 'seed': REAL_SEED, 'split_seed': SPLIT_SEED},
        baseline=baseline, candidates=candidates,
        quality={'train_loss': lora['train_loss'], 'val_loss': lora['val_loss'], 'baseline_val_loss': baseline['val_loss'], 'data_audit': _matched_audit(examples, REAL_MAX_SEQ_LEN * 4)},
        resources={'trainable_params': lora['trainable_params'], 'peak_memory_mb': lora['peak_memory_mb'], 'step_time_ms': lora['step_time_ms'], 'tokens_per_s': lora['tokens_per_s']},
        artifacts={'adapter': str(project_root / 'benchmarks' / 'results' / '60_real_lora' / 'matched_adapter'), 'report': str(project_root / 'benchmarks' / 'results' / '60_real_lora' / '60_real_lora_matched.json')},
        decision={'decision': 'tune', 'reason': 'matched baseline 与 LoRA 已完成,仍需增加重复运行和任务指标后再决定是否采用。', 'next_action': 'repeat_with_fixed_validation_and_task_metric'},
        environment={'python': sys.version, 'torch': torch.__version__, 'torch_cuda': torch.version.cuda, 'device': torch.cuda.get_device_name(0)},
    )
    report_dir = project_root / 'benchmarks' / 'results' / '60_real_lora'
    report_path = report_dir / '60_real_lora_matched.json'
    seed_report_path = report_dir / f'60_real_lora_matched_seed{REAL_SEED}.json'
    report_text = json.dumps(report, ensure_ascii=False, indent=2) + '\n'
    report_path.write_text(report_text, encoding='utf-8')
    seed_report_path.write_text(report_text, encoding='utf-8')
    print(f'报告已保存:{report_path}')
    print(f'按 seed 保存:{seed_report_path}')
    print(json.dumps(report, ensure_ascii=False, indent=2))
else:
    print('跳过 matched baseline:保持 CPU-first / smoke-test 模式。')

Released under the MIT License.