1. 项目概述:LESS——为特定能力“定向投喂”数据
在大型语言模型(LLM)的指令微调阶段,我们常常面临一个经典难题:数据并非越多越好,而是越“对”越好。全量数据训练不仅耗费巨大的计算资源,还可能因为数据中混杂了大量与目标能力无关甚至冲突的样本,导致模型学习效率低下,甚至发生“灾难性遗忘”。想象一下,你想训练一个模型精通代码生成,却用一份混杂了诗歌创作、客服对话和历史问答的数据集去微调,效果可想而知。这正是普林斯顿NLP团队提出LESS方法的出发点:如何从海量的指令数据中,精准筛选出那些对提升模型某一项特定能力最有影响力的样本?
LESS的核心思想非常直观且有力:它通过计算训练数据对目标验证任务(即你想提升的能力)的“影响力分数”,来量化每个训练样本的价值。其理论基础源于经典的影响函数,但LESS的创新之处在于,它通过一系列巧妙的工程化设计(如梯度投影、多检查点集成、LoRA高效训练),将这套理论变成了一个可扩展、可实操的完整数据选择流水线。简单来说,LESS帮你回答:“在我这TB级的指令数据里,到底哪些数据点,能最有效地让我的模型在‘数学推理’或‘多语言问答’上表现更好?”
如果你正在为以下问题头疼,那么LESS值得你深入关注:1)计算预算有限,无法进行全量数据微调;2)希望针对性地提升模型在某个垂直领域或任务上的表现;3)担心混合数据微调会导致模型能力“平均化”或产生负迁移。接下来,我将结合论文与代码,拆解LESS的每一个步骤,并分享在实际复现和应用中可能遇到的“坑”与技巧。
2. LESS核心原理与设计思路拆解
2.1 影响力估计:从理论到实践的桥梁
LESS方法的基石是影响函数。在统计学和机器学习中,影响函数用于衡量移除或扰动一个训练数据点会对模型参数(进而对模型在某个测试点上的预测)产生多大影响。其数学形式通常涉及计算损失函数关于模型参数的Hessian矩阵的逆,这对于现代大模型来说是完全不可行的计算负担。
LESS巧妙地避开了直接计算Hessian逆这个“怪兽”。它采用了一种基于梯度内积的近似方法。核心直觉是:如果一个训练样本的梯度方向与目标验证任务的梯度方向高度一致,那么用这个样本训练模型,就会将参数向有利于解决验证任务的方向推动,即该样本对目标任务有正影响力。具体来说,对于某个训练样本z_i和验证集D_val,其影响力分数I(z_i, D_val)可以近似为:
I(z_i, D_val) ≈ -η * <∇_θ L(z_i, θ), Σ_{z_val in D_val} ∇_θ L(z_val, θ)>
其中,η是学习率,<·, ·>表示内积。这个公式的含义是:计算训练样本损失梯度与验证集损失梯度之和的内积。内积值越大(负得越少或正得越多),说明该训练样本的梯度方向与验证任务的需求越“同向”,影响力也就越大。
注意:这里有一个关键细节,原始影响函数公式前通常有一个负号。在LESS的实现中,它最终选择的是内积值最大的样本,这对应的是最负的影响力(即,移除该样本会导致验证损失最大幅度增加),或者说该样本对降低验证损失最有帮助。理解这一点对解读代码中的排序逻辑很重要。
2.2 工程化挑战与LESS的解决方案
直接将上述理论应用于LLM会面临三大挑战:
- 梯度维度灾难:LLaMA-7B有70亿参数,梯度是70亿维向量,存储和计算内积的内存与计算开销无法承受。
- 训练动态变化:模型在训练过程中参数不断变化,单个检查点下的梯度代表性不足。
- 计算效率:为海量训练数据逐个计算并存储高维梯度不现实。
LESS的流水线设计正是为解决这些问题而生:
- 应对维度灾难(梯度投影):LESS不存储原始梯度,而是将梯度投影到一个随机低维子空间(例如8192维)。这通过一个固定的随机投影矩阵实现,能大概率保留向量间的角度关系(即内积符号),从而在极大降低存储成本的同时,保持了影响力排序的可靠性。
- 捕捉训练动态(多检查点集成):模型在训练不同阶段关注的数据特性不同。LESS在预热训练后,采集多个检查点下的训练数据梯度,并在计算影响力时进行加权平均,从而得到一个更稳健、全面的影响力评估。
- 提升计算效率(LoRA与梯度缓存):整个流程基于LoRA进行微调,大幅减少了需要优化的参数量,使得梯度计算和存储变得可行。同时,LESS将训练数据的投影梯度预先计算并存储为“梯度数据存储库”,在针对不同目标任务进行选择时,只需计算一次目标任务的梯度,然后进行高效的内积运算即可,实现了“一次计算,多次选择”。
这套组合拳使得LESS从一个理论构想,变成了一个能在实际LLM训练流程中无缝集成的实用工具。
3. 实操部署与环境搭建详解
3.1 环境配置与依赖安装
LESS的代码库对环境有一定要求。以下是我在复现过程中总结的详细步骤和避坑指南。
首先,PyTorch的版本匹配是第一个关键点。原代码要求torch==2.1.2。如果你使用的CUDA版本较新(如12.1),直接安装可能会失败。你需要从PyTorch官方历史版本页面找到与你的CUDA及系统匹配的安装命令。
# 示例:对于CUDA 12.1,可以尝试安装2.1.2版本 pip install torch==2.1.2 torchvision==0.16.2 torchaudio==2.1.2 --index-url https://download.pytorch.org/whl/cu121如果遇到兼容性问题,一个更稳妥的方法是使用作者提供的requirements.txt中的版本,它通常经过测试。安装完PyTorch后,进入项目目录安装其他依赖。
git clone https://github.com/princeton-nlp/LESS.git cd LESS pip install -r requirements.txt实操心得:强烈建议在安装前创建一个新的conda或venv虚拟环境。这能避免与本地其他项目的包版本冲突。我曾因环境中transformer库版本不一致,导致梯度计算脚本报出难以追踪的形状错误。
最后,以可编辑模式安装less包本身,这样你可以直接修改源码,并且任何导入less模块的脚本都能使用最新代码。
pip install -e .3.2 数据准备与预处理
LESS实验使用了四个指令微调数据集:Flan v2, CoT, Dolly, Open Assistant。其数据处理脚本遵循了 open-instruct 项目的格式。最方便的方式是直接使用作者在Hugging Face上提供的已处理版本。
# 从Hugging Face下载已处理的数据 git lfs install git clone https://huggingface.co/datasets/princeton-nlp/less_data ../data下载后,你的../data目录结构应如下所示:
data/ ├── train/ │ ├── processed/ │ │ ├── flan_v2/ │ │ ├── cot/ │ │ ├── dolly/ │ │ └── oasst1/ │ └── raw/ # 可能包含原始数据 └── eval/ ├── mmlu/ ├── tydiqa/ └── bbh/注意事项:务必检查数据格式。每个
jsonl文件中的每一行应是一个字典,至少包含"instruction","input","output"字段(或类似的prompt-completion对)。LESS的数据加载器期望这种格式。如果你有自己的数据集,需要先预处理成相同格式。一个常见的错误是数据字段名不匹配,导致脚本运行时找不到关键信息而静默失败或报错。
4. LESS数据选择流水线分步实现
4.1 第一步:预热训练
预热训练的目的不是得到一个好模型,而是为了获得一个合理的参数起点,并确定后续梯度采集的检查点。LESS使用全量数据的5%进行LoRA微调。
export DATA_DIR=../data export MODEL_PATH=meta-llama/Llama-2-7b-hf export PERCENTAGE=0.05 export DATA_SEED=3 export JOB_NAME=llama2-7b-p${PERCENTAGE}-lora-seed${DATA_SEED} ./less/scripts/train/warmup_lora_train.sh "$DATA_DIR" "$MODEL_PATH" "$PERCENTAGE" "$DATA_SEED" "$JOB_NAME"关键参数解析:
PERCENTAGE=0.05: 使用5%的随机数据。这个比例是经验值,足以让模型初步学习指令遵循的模式,又不会开销太大。DATA_SEED=3: 随机种子,确保数据可复现。不同的种子会导致采样的5%数据不同,可能会影响最终选择结果,这是一个可以探索的超参数。JOB_NAME: 用于定义输出目录,方便管理。
脚本内部做了什么?
- 从四个数据集中随机采样指定百分比的数据并合并。
- 使用QLoRA(4-bit量化)加载Llama-2-7B模型。
- 在合并的数据上训练一个LoRA适配器(通常作用于所有线性层)。
- 按照固定的间隔(如每1000步)保存检查点。
踩坑记录:预热训练的步数和检查点保存频率需要根据你的总数据量和batch size调整。原脚本可能默认训练一个epoch。你需要确保训练足够步数,使模型损失明显下降,同时保存足够多(如4-5个)均匀分布的检查点,以供后续梯度采集。检查点太少会影响多检查点集成的效果。
4.2 第二步:构建梯度数据存储库
这是LESS最耗资源的步骤,但只需执行一次。我们需要为全部训练数据(而不仅仅是预热用的5%)在多个检查点下计算并存储其投影梯度。
CKPT=105 # 假设这是第一个检查点 TRAINING_DATA_NAME=dolly TRAINING_DATA_FILE=../data/train/processed/dolly/dolly_data.jsonl GRADIENT_TYPE="adam" # 对于训练数据,使用Adam优化器状态中的梯度估计 MODEL_PATH=../out/llama2-7b-p0.05-lora-seed3/checkpoint-${CKPT} OUTPUT_PATH=../grads/llama2-7b-p0.05-lora-seed3/${TRAINING_DATA_NAME}-ckpt${CKPT}-${GRADIENT_TYPE} DIMS="8192" # 投影维度 ./less/scripts/get_info/get_train_lora_grads.sh "$TRAINING_DATA_FILE" "$MODEL_PATH" "$OUTPUT_PATH" "$DIMS" "$GRADIENT_TYPE"你需要对每个数据集(flan_v2, cot, dolly, oasst1)和每个选定的检查点(如105, 211, 317, 420)都运行此脚本。
核心过程解读:
- 脚本加载指定检查点的模型(包含LoRA权重)。
- 遍历指定数据文件的每一个样本。
- 对于每个样本,进行前向传播计算损失,然后反向传播得到梯度。
- 关键一步:梯度投影。脚本会将模型参数的梯度(只针对LoRA可训练参数,已大幅减少)拼接成一个巨大向量,然后与一个预先生成的随机高斯矩阵(维度为
[总参数量, DIMS])相乘,得到一个仅DIMS维的投影梯度向量。 - 将这个低维向量,连同样本的唯一标识(如索引)和损失值,保存到
OUTPUT_PATH目录下的.npy或.pkl文件中。
重要提示:
GRADIENT_TYPE="adam"是一个精妙的处理。由于我们使用Adam优化器,其更新方向不是纯梯度,而是经过一阶矩和二阶矩估计修正后的方向。使用Adam状态能更好地模拟实际参数更新方向,比原始SGD梯度更有意义。这是论文中的一个重要细节。
4.3 第三步:为目标任务选择数据
现在,假设我们想提升模型在tydiqa(多语言问答)任务上的表现。
3.1 计算目标任务的梯度:首先,我们需要目标验证任务的投影梯度。流程与第二步类似,但数据换成了验证集,且GRADIENT_TYPE固定为"sgd"(即原始梯度)。
TASK=tydiqa CKPT=105 MODEL_PATH=../out/llama2-7b-p0.05-lora-seed3/checkpoint-${CKPT} OUTPUT_PATH=../grads/llama2-7b-p0.05-lora-seed3/${TASK}-ckpt${CKPT}-sgd DATA_DIR=../data DIMS="4096 8192" # 可以计算多个维度的投影,后续选择使用哪个 ./less/scripts/get_info/get_eval_lora_grads.sh "$TASK" "$DATA_DIR" "$MODEL_PATH" $OUTPUT_PATH "$DIMS"同样,需要对每个检查点运行此脚本。
3.2 计算影响力分数并选择Top-K数据:这是LESS的“魔法”发生处。脚本会计算每个训练数据样本(来自所有数据集、所有检查点)与目标任务梯度之间的加权内积。
DIM=8192 # 选定使用的投影维度 GRADIENT_PATH=../grads/llama2-7b-p0.05-lora-seed3/{}-ckpt{}-adam/dim${DIM} TRAIN_FILE_NAMES="flan_v2 cot dolly oasst1" CKPTS="105 211 317 420" CHECKPOINT_WEIGHTS="1.6877e-05 1.2859e-05 7.7030e-06 2.5616e-06" # 对应检查点的平均学习率 VALIDATION_GRADIENT_PATH=../grads/llama2-7b-p0.05-lora-seed3/{}-ckpt{}-sgd/dim${DIM} TARGET_TASK_NAMES="tydiqa" SELECTED_DATA_OUTPUT_PATH="../selected_data" ./less/scripts/data_selection/matching.sh "$GRADIENT_PATH" "$TRAIN_FILE_NAMES" "$CKPTS" "$CHECKPOINT_WEIGHTS" "$VALIDATION_GRADIENT_PATH" "$TARGET_TASK_NAMES" "$SELECTED_DATA_OUTPUT_PATH"权重CHECKPOINT_WEIGHTS的奥秘:这里使用的是对应检查点时的平均学习率。为什么?在影响函数的理论推导中,数据点的影响力与学习率η线性相关。在训练后期,学习率衰减,相同梯度带来的参数更新变小,因此其影响力也应该按比例缩放。使用学习率作为权重,是对多检查点梯度进行合理加权平均的关键。
运行后,脚本会为tydiqa任务生成一个包含所有训练样本影响力分数的文件。
3.3 提取选定数据:最后,根据影响力分数排序,选出Top K%(例如5%)的样本。
python3 -m less.data_selection.write_selected_data \ --target_task_names ${TARGET_TASK_NAMES} \ --train_file_names ${TRAIN_FILE_NAMES} \ --train_files ../data/train/processed/dolly/dolly_data.jsonl ../data/train/processed/oasst1/oasst1_data.jsonl \ --output_path $SELECTED_DATA_OUTPUT_PATH \ --percentage 0.05这个脚本会从原始训练文件中,提取出影响力最高的那部分数据,合并成一个新的jsonl文件,例如../selected_data/tydiqa/top_p0.05.jsonl。
4.4 第四步:使用选定数据训练
至此,我们获得了一份“精华”数据。接下来就是用这份数据对基础模型进行指令微调。
TARGET_TASK_NAME="tydiqa" PERCENTAGE=0.05 TRAIN_FILES=../selected_data/${TARGET_TASK_NAME}/top_p${PERCENTAGE}.jsonl MODEL_PATH=meta-llama/Llama-2-7b-hf JOB_NAME=llama2-7b-less-p${PERCENTAGE}-lora ./less/scripts/train/lora_train.sh "$TRAIN_FILES" "$MODEL_PATH" "$JOB_NAME"你可以对比使用全量5%随机数据训练的效果,和使用LESS选出的5%数据训练的效果。论文中的实验表明,在多项评测上,LESS选出的数据都能显著超越随机选择。
5. 关键参数调优与经验分享
LESS的流程中有几个关键超参数,对最终效果有直接影响。
5.1 投影维度DIMS的选择
投影维度是平衡计算开销和精度的关键。论文实验了{512, 1024, 2048, 4096, 8192}等维度。
- 维度越低:存储和计算速度越快,但梯度方向的信息损失可能越大,可能导致影响力排序不准确。
- 维度越高:保真度越高,但开销越大。
- 经验值:论文默认使用
8192,这是一个在效果和效率间取得较好平衡的点。对于更大的模型(如70B),你可能需要尝试更高的维度(如16384),但会显著增加内存消耗。一个可行的策略是先用小维度(如4096)跑一遍,观察选出的数据是否合理,再决定是否增加维度。
5.2 预热训练与检查点选择
- 预热数据比例 (
PERCENTAGE):默认5%是一个安全的起点。如果你的总数据量极大(数千万),可以适当降低比例(如1%)。如果总数据量较小,可以提高到10%。核心是让模型学到基本的指令遵循格式。 - 检查点数量与位置:论文选择了4个检查点。理想情况下,检查点应覆盖训练的不同阶段(早期、中期、中后期)。你需要根据预热训练的总步长来规划。例如,如果训练10,000步,可以在
[2000, 4000, 6000, 8000]步保存检查点。避免所有检查点都集中在训练开始或结束阶段。
5.3 多目标任务的数据选择
LESS天然支持为多个目标任务选择数据。你只需在TARGET_TASK_NAMES中指定多个任务,例如TARGET_TASK_NAMES="tydiqa mmlu bbh"。脚本会计算每个训练样本对每个任务的影响力分数。最终的策略可以是:
- 并集:选取对任一任务影响力高的样本。
- 交集:选取对所有任务影响力都高的样本(可能数量很少)。
- 加权和:为不同任务分配权重,计算加权影响力总分。 原版脚本似乎采用的是并集策略。你可以修改选择逻辑来实现更复杂的策略。
6. 常见问题排查与效能优化
6.1 内存不足与计算优化
构建梯度数据存储库是内存消耗最大的阶段,尤其是处理大规模数据集时。
- 梯度计算批处理:原脚本可能是逐样本计算梯度。你可以修改
get_train_lora_grads.sh及相关Python代码,引入小批量计算。例如,每次处理32或64个样本,计算平均梯度作为该批次的代表。这能大幅减少磁盘I/O和循环开销,是处理超大数据集的必备优化。但需注意,批处理会损失样本级粒度,是一种近似。 - 分片存储:不要试图将一个包含数百万样本的数据集的梯度存成一个巨大的文件。应该按检查点、按数据集分片存储,例如每1万个样本存一个文件。这便于管理和后续的分布式读取。
- 使用CPU离线计算:如果GPU内存严重不足,可以考虑将梯度投影的计算转移到CPU上进行。虽然慢,但可行性高。在脚本中,将投影矩阵与梯度的矩阵乘法操作放到CPU上执行,然后再存回磁盘。
6.2 脚本执行错误与调试
- 路径错误:LESS脚本大量使用相对路径。确保你在项目根目录(
LESS/)下执行脚本,并且DATA_DIR、MODEL_PATH、OUTPUT_PATH等环境变量指向的路径存在且有权访问。 - 数据格式错误:如果出现
KeyError,大概率是数据格式问题。使用一小部分数据,打印出数据加载器读取的第一个样本,检查字段名是否与代码中的instruction_key、input_key、output_key等匹配。 - 版本冲突:确保
transformers,accelerate,peft等库的版本与requirements.txt一致。特别是peft库,其API在近期版本有较大变动,可能导致LoRA模型加载失败。
6.3 效果不达预期如何分析
如果你复现后发现LESS选出的数据训练效果不如随机选择,可以从以下方面排查:
- 验证任务与训练数据的相关性:LESS的前提是训练数据集中存在能提升目标任务的样本。如果任务本身非常冷门(如某种极小众语言的语法分析),而你的指令数据集中几乎没有相关数据,那么LESS也无能为力。它只能“发现”金子,不能“创造”金子。
- 投影维度是否过低:尝试将
DIM从4096提升到8192或更高,看影响力排序是否有显著变化。 - 检查点代表性:检查预热训练是否收敛?损失曲线是否平滑下降?检查点是否捕捉了有代表性的训练状态?可以尝试增加预热训练数据比例或步数,并保存更多检查点。
- 影响力计算是否正确:手动验证一个小样本。随机挑10个训练样本和10个验证样本,用代码计算它们的内积,看看分数最高的样本是否“看起来”更相关(例如,验证任务是数学题,高分训练样本是否也包含数学推理?)。这是一种快速的定性检查。
LESS为我们提供了一种数据驱动的、可解释的指令数据选择方法。它将宝贵的计算资源从“蛮力训练”转向了“精准投喂”。尽管其初始的梯度计算成本不低,但对于需要反复在不同目标任务上微调模型,或拥有超大规模指令池的团队来说,构建一次梯度数据存储库,即可长期、高效地服务于多种能力提升需求,从长远看是极具性价比的投资。在实际应用中,你可以从一个小规模试点开始,例如在某个垂直领域(如法律、医疗)的指令数据上应用LESS,验证其对你特定模型和任务的有效性,再决定是否扩大到全量流程。