【从零开始】15. “小”模型微调

161 阅读21分钟

书接上回,上回我们已经做完了数据质量评分与同质化数据删除工作,为了方便后续的训练我们还是先将数据全部做成离线版本的 JSON 数据集(因为考虑到训练环境无法连接现有数据库的情况)。代码如下:

...

class TurningDataPreparer:

    def clean_and_filter_data(self, data: List[Dict]) -> List[Dict]:
        
        cleaned_data = []

        # 获取数据集并分解问题和答案两部分
        for item in data:
            question = item["messages"][0]["content"]
            answer = item["messages"][1]["content"]

            # 如果数据长度太短则排除
            if len(question) < 5 or len(answer) < 5:
                continue
            # 同理,若数据太长也排除
            if len(question) > 10000 or len(answer) > 10000: 
                continue
            # 如果问题和答案都是相同的也排除(没有意义)
            if question == answer: 
                continue

            # 这之后将所有合适的内容都放入数组中
            cleaned_data.append(item)

        logger.info(f"After data cleaning, there are {len(cleaned_data)} entries remaining")
        return cleaned_data

    def extract_data_from_es(self) -> List[Dict]:
        
        logger.info("Begin to extract data from Elasticsearch...")
        all_data = []
        
        # 获取全部数据的平均分
        search_avg_sql = f"select avg(avg_score) from {CU.TMP_ES_INDEX} where process_status = 1"
        avg_score_results = self.elastic.find_by_sql(search_avg_sql)
        avg_score = avg_score_results.body["rows"][0][0]
        
        # 获取所有数据来源
        self.data_sources = []
        data_source_group_sql = f"select data_source from {CU.TMP_ES_INDEX} group by data_source"
        data_source_results = self.elastic.find_by_sql(data_source_group_sql)
        data_sources_rows = data_source_results.body["rows"]
        self.data_sources.extend(data_sources_row[0] for data_sources_row in data_sources_rows)

        # 根据数据来源分类进行遍历
        for source in self.data_sources:
            logger.info(f"Loading data source: {source}")

            # 组装查询 dsl,条件是即是当前数据来源的,并且是“已评分状态(process_status=1)”,并且是高于所有数据的平均分
            search_body = {
                "size": ES_BATCH_COUNT,
                "query": {
                    "bool": {
                        "must": [
                            {"term": {"data_source": {"value": source}}},
                            {"term": {"process_status": {"value": 1}}},
                            {"range": {"avg_score": {"gt": avg_score,"boost": 1}}}
                        ],
                        "boost": 1
                    }
                }
            }

            # 分页查询
            response = self._conn.search(index=CU.TMP_ES_INDEX,body=search_body,scroll='2m')
            scroll_id = response['_scroll_id']
            hits = response['hits']['hits']
            while hits:
                for hit in hits:
                    row_entity = hit["_source"]

                    # 组装训练用数据格式(message 模式)
                    all_data.append({
                        "messages": [
                            {"role": "user","content": row_entity["question"]},
                            {"role": "assistant","content": row_entity["answer"]}
                        ],
                        "source": source
                    })

                # 获取下一批次数据
                response = self._conn.scroll(scroll_id=scroll_id,scroll='2m')
                hits = response['hits']['hits']

                # 训练数据集大小阈值
                # 即数据集大小可允许是预设值的 1.2 倍,在保证数据集内容完整的前提下进行分割
                if len(all_data) >= self.dataset_size * 1.2:
                    break
            if len(all_data) >= self.dataset_size * 1.2:
                break
        logger.info(f"Total {len(all_data)} records extracted from Elasticsearch")
        return all_data

    def prepare_and_save_dataset(self):
        # 数据集保存路径
        output_dir = os.path.join(self.data_path, self.turning_dir)
        os.makedirs(output_dir, exist_ok=True)

        # 获取所有数据集
        all_data = self.extract_data_from_es()
        logger.info(f"Total {len(all_data)} records extracted from Elasticsearch")

        # 检查数据集长度并剔除不合符规格的数据
        all_data = self.clean_and_filter_data(all_data)
        logger.info(f"Total {len(all_data)} records cleaned and filtered")

        # 按照验证数据集的百分比进行整体数据集的切割并生成训练数据集和验证数据集
        if self.validation_split > 0:

            # 将总数据按比例拆分成训练数据集和验证数据集
            train_data, val_data = train_test_split(
                all_data,
                test_size=self.validation_split,
                random_state=42
            )
            logger.info(f"Training set size: {len(train_data)}")
            logger.info(f"Validation set size: {len(val_data)}")

            # 保存训练数据集
            train_path = os.path.join(output_dir, "train_dataset.json")
            with open(train_path, 'w', encoding='utf-8') as f:
                json.dump(train_data, f, ensure_ascii=False, indent=2)
            logger.info(f"Training set saved to: {train_path}")

            # 保存验证数据集
            val_path = os.path.join(output_dir, "validation_dataset.json")
            with open(val_path, 'w', encoding='utf-8') as f:
                json.dump(val_data, f, ensure_ascii=False, indent=2)
            logger.info(f"Validation set saved to: {val_path}")
        else:
            train_path = os.path.join(output_dir, "train_dataset.json")
            with open(train_path, 'w', encoding='utf-8') as f:
                json.dump(all_data, f, ensure_ascii=False, indent=2)
            logger.info(f"Training set saved to: {train_path}")

        # 元数据信息
        metadata = {
            "total_samples": len(all_data),
            "train_samples": len(train_data) if self.validation_split > 0 else len(all_data),
            "validation_samples": len(val_data) if self.validation_split > 0 else 0,
            "validation_split": self.validation_split,
            "data_sources": self.data_sources
        }

        # 将数据集的生成信息都写到元数据文件中
        # 基于数据可追溯原则,需要将生成数据集的来源记录下来
        metadata_path = os.path.join(output_dir, "dataset_metadata.json")
        with open(metadata_path, 'w', encoding='utf-8') as f:
            json.dump(metadata, f, ensure_ascii=False, indent=2)
        logger.info(f"Metadata saved to: {metadata_path}")

if __name__ == "__main__":
    preparer = TurningDataPreparer()
    preparer.prepare_and_save_dataset()

通过以上脚本可以将数据导出成三个 json 文件,train_dataset.json、validation_dataset.json 和 dataset_metadata.json。其中 train_dataset.json 是训练数据,而 validation_dataset.json 是按比例生成的验证数据(这里我设置了总数据量的 10% 用于验证)。

至于 dataset_metadata.json 则是生成数据的元数据信息,用于告诉模型训练人员数据总量多少、训练数据与验证数据的比例多少、数据来源等信息做到有迹可循。如下图:

{
  "total_samples": 39457,
  "train_samples": 35511,
  "validation_samples": 3946,
  "validation_split": 0.1,
  "data_sources": [
    "five-phases-mindset",
    "hwtcm-deepseek",
    "hwtcm-sft-v1",
    "shennong-tcm"
  ]
}

云算力选择

有了离线数据后,就需考虑训练算力问题了(这是一个绕不开的话题)。

根据网上的一些“白嫖指南”我尝试了几款算力平台(如:腾讯的 Cloud Studio、中国移动的九天·毕昇、OpenI启智等),但实际体验后还是觉得阿里的 PAI 平台最适合自己。

像我们这种一般 NLP 模型训练用 PAI-DSW 就够了,新用户可申请免费试用三个月共计 750 算时。但这些都不是重点,重点是 PAI-DSW 即使在试用期间也不会因“不操作”而自动释放资源。

这对于模型训练来说就非常重要,一般模型训练动辄十几个小时,谁会一直守在屏幕前等呢?而 PAI 在低利用率 3 小时后才关机,这就非常贴心了(当然了,如果硬要白嫖,那些“不操作就释放”的规则也是能搞定的。譬如写个定时 shell 脚本每分钟“刷”一下操作时间也行,不过这就没意思了吧)。

此外,魔塔社区也有跟阿里合作,在魔塔社区也可以申请 36 小时的 PAI-DSW 免费实例,不用白不用。

不过需要注意的是,如果你的训练数据是涉及隐私内容又或者有知识产权限制就不建议使用云算力进行训练了,毕竟人家也不是做慈善的,你数据上传到平台那就不由得你了。你以为为什么能够给你白嫖?除了吸纳你这位新用户之外,数据也是重要的无形资产...你细品吧。

线下训练

考虑到还是会有“线下训练”的需求,因此对 brain-mix 项目代码进行调整(已完整适配线上、线下训练模式)。并通过关系借用老东家的 RTX A6000 服务器进行线下模型的训练。伪代码如下:

...
# 超参数自动优化框架(optuna)
import optuna

# 为了减少显存的使用使用了 unsloth 进行训练
# 此外,这里要注意加载顺序。unsloth要放在 trl、transformers 和 peft 的最上方不然会引发警告
from unsloth import FastLanguageModel,unsloth_train
from unsloth.chat_templates import train_on_responses_only
from trl import SFTTrainer
from transformers import TrainingArguments
from peft import PeftModel

class ModelAutoTurning:

    def __init__(self, **kwargs) -> None:
        
        # ... 初始化参数

    def load_configurations(self):
        
        # ... 读取配置信息

    def setup_gpu_optimization(self):

        # ... 检查系统算力情况

    def load_training_data_from_json(self) -> Optional[DatasetDict]:

        # ... 加载训练数据和验证数据

    def format_dataset_with_chat_template(self, dataset, tokenizer):

        # ... 将训练数据和验证数据转换为模型对话模板格式
        
    def run_training_session(self, model, tokenizer, dataset_dict, output_dir, hyperparameters):
        
        # ... 组装训练参数并开始训练,最终返回训练结果

    def objective(self, trial: optuna.Trial) -> float:

        # ... 核心训练函数,通过 Optuna 自动搜索最佳的超参数组合(如学习率、LoRA 秩、批处理大小等)并且在训练过程中遇到性能瓶颈时实现自动降级
        # 下一轮训练参数的组合将通过过往训练结果决定,实现真正意义上的自主训练
        # PS:虽然很多开源训练框架都已经有这个功能,但自己来实现还是有不一样的感觉,这或许是我还坚持编程的理由吧

    def save_comprehensive_report(self, study: optuna.Study, save_dir: str):

        # ... 保存训练结果并产生报告

    def save_markdown_report(self, report: Dict, save_dir: str):
        
        # ... 生成 markdown 文件

    def merge_and_save_for_inference(self, best_adapter_path: str, final_save_dir: str) -> None:

        # ... 合并模型

    def model_finetuning(self):
        
        # ... 模型训练主入口

if __name__ == "__main__":
    trainer = ModelAutoTurning()
    trainer.model_finetuning()

从伪代码可知,整个代码的核心逻辑可以分为以下四个主要阶段:

  1. 设置与配置

脚本首先从 YAML 配置文件中加载所有必要的参数(如模型名称、数据集路径、超参数搜索范围等)。然后,它会检测 GPU 环境并设置相应的优化选项(例如,为新一代安培架构 GPU 启用 bfloat16)。最后,从指定的 JSON 文件中加载训练和验证数据集。

对应函数:load_configurations、setup_gpu_optimization、load_training_data_from_json、format_dataset_with_chat_template

  1. 超参数搜索 (核心循环)

这是自动化的核心。Optuna 会启动一个循环,在预设的 max_trials 次数内不断尝试:

  • 在每一次“试验”(Trial)中,Optuna 从预设的搜索空间里推荐一组新的超参数。
  • 脚本使用这组参数来配置 LoRA 适配器,并加载 4-bit 量化的基础模型进行训练。
  • 训练过程中包含了强大的错误处理机制:如果发生“显存不足”(Out of Memory, OOM)的错误,它会自动尝试降低批处理大小(batch size)并重新训练。
  • 每次试验结束后,将模型的验证集损失(或训练集损失)返回给 Optuna。Optuna 会根据这个分数来决定下一轮试验应该尝试什么样的参数组合。

对应函数:run_training_session、objective、model_finetuning

  1. 训练后处理与报告

当所有试验都完成后

  • 脚本会根据最低的损失分数找出“最佳试验”。
  • 它会将这次最佳试验产出的 LoRA 适配器模型保存下来,并删除所有其他试验产生的临时模型,以节省空间。
  • 最后,生成一份非常详细的训练报告(同时有 JSON 和 Markdown 两种格式),总结所有试验的结果并展示最佳模型的各项指标。

对应函数:save_comprehensive_report、save_markdown_report

  1. 最终模型准备
  • 为了方便后续的部署和推理,脚本会将最佳的 LoRA 适配器的权重合并回原始的基础模型中。
  • 这个过程会生成一个完整的、独立的模型文件,可以直接用于生产环境,不再需要额外的适配器文件。

对应函数:merge_and_save_for_inference

对应的四阶段具体流程如下:

核心代码讲解

第一和第三阶段都没有什么好说的了,下面将对第二阶段和第四阶段展开说明。

不得不说,使用了 Optuna 让我省下了很多时间,毕竟原本的代码我是想将所有的超参数组合都试一遍,然后再挑选出最优结果的。想法是很好,但是显然在有限的资源和时间里面,这种做法是不太现实的。那么,随机挑选几个超参数组合呢?这样也有一定机率错失掉原本更好的训练组合。而 Optuna 很好地解决了这个问题。

首先我看先看看调用入口 model_finetuning 函数

def model_finetuning(self):
    ... 
    
    # 创建一个 Optuna 的 Study 对象,它负责管理整个优化任务。
    # 然后,正式开始试验循环(study.optimize),循环的次数由这里的 self.max_trials 决定。
    study = optuna.create_study(direction="minimize")
    study.optimize(self.objective, n_trials=self.max_trials)
    ...

由于 model_finetuning 函数中调用了 objective 函数,那么我们紧接着看看 objective 函数的内容

def objective(self, trial: optuna.Trial) -> float:
        
    try:
        # 每次循环的开始,Optuna 会根据过去的试验结果“智能地”推荐一组它认为可能会有更好表现的新超参数。
        hyperparameters = {
            key: trial.suggest_categorical(key, value['choices'])
            for key, value in self.search_space_config.items()
        }

        # 先看看建议中选择的超参数有哪些
        logger.info(f"\n{'='*60}\nTrial {trial.number}/{self.max_trials} - Optuna 建议参数:")
        logger.info(json.dumps(hyperparameters, indent=2))

        # 执行这些超参数训练
        current_hyperparameters = hyperparameters.copy()
        while True:
            try:
                # 使用新参数准备模型
                # 以 4-bit 低精度模式加载基础模型以节省显存
                model, tokenizer = FastLanguageModel.from_pretrained(
                    model_name=self.base_model,
                    max_seq_length=int(current_hyperparameters.get('max_seq_length')),
                    dtype=self.compute_dtype,
                    load_in_4bit=True
                )

                # 获取 PEFT model
                # 将新参数(如 r, lora_alpha)的 LoRA 适配器附加到模型上。
                model = FastLanguageModel.get_peft_model(
                    model,
                    r=int(current_hyperparameters.get('r')),
                    target_modules=self.target_modules,
                    lora_alpha=int(current_hyperparameters.get('lora_alpha')),
                    lora_dropout=0.0,
                    bias="none",
                    use_gradient_checkpointing="unsloth",
                    random_state=42
                )

                # 这里调用了 run_training_session 函数来训练模型
                current_model_path = os.path.join(self.output_dir, f"trial_{trial.number}")
                results = self.run_training_session(model, tokenizer, self.dataset_dict, current_model_path, current_hyperparameters)

                # 获取训练得分
                score = results.get('eval_loss') if results.get('eval_loss') is not None else results.get('train_loss', float('inf'))

                # 设置用户训练结果
                # 这个非常重要,因为下一轮训练需要根据过往训练结果进行判断
                trial.set_user_attr("model_path", current_model_path)
                trial.set_user_attr("full_results", {**results, "hyperparameters": current_hyperparameters})

                # 打印训练结果
                logger.info(f"Trial {trial.number} 完成。得分 (Loss): {score:.4f}")

                # 清理显存使用
                del model, tokenizer
                gc.collect()
                torch.cuda.empty_cache()

                return score

            except torch.cuda.OutOfMemoryError:
                # 如果遇到 OOM 的情况,那肯定是先回收显存
                gc.collect()
                torch.cuda.empty_cache()

                # 更新超参数配置
                # 具体做法是检查当前的批次大小(batch size)是否大于1。
                # 如果是,就将其减半并重新尝试 K_try 训练步骤。
                current_batch_size = int(current_hyperparameters.get("per_device_train_batch_size"))
                if current_batch_size > 1:
                    new_batch_size = max(1, current_batch_size // 2)
                    current_hyperparameters["per_device_train_batch_size"] = new_batch_size
                    logger.warning(f"OOM: 尝试将 batch_size 降低到 {new_batch_size} 后重试。")
                else:
                    logger.error("OOM: batch_size 已经降低到 1,无法继续运行。")
                    raise optuna.exceptions.TrialPruned()

            except Exception as e:
                # 若其他报错则中断训练
                logger.error(f"Trial {trial.number} 发生未知严重错误: {str(e)}", exc_info=True)
                raise optuna.exceptions.TrialPruned()

    except Exception as e:
        logger.error(f"在 objective 函数中捕获到意外错误: {e}")
        return float('inf')

既然 objective 调用了 run_training_session ,那么也看看 run_training_session 函数的具体实现吧。

def run_training_session(self, model, tokenizer, dataset_dict, output_dir, hyperparameters):
    
    # 加载格式化数据集(训练数据和验证数据)
    formatted_dataset_dict = DatasetDict()
    formatted_dataset_dict['train'] = self.format_dataset_with_chat_template(dataset_dict['train'], tokenizer)
    if 'validation' in dataset_dict:
        formatted_dataset_dict['validation'] = self.format_dataset_with_chat_template(dataset_dict['validation'], tokenizer)

    # 组装训练用的参数组合
    training_args = TrainingArguments(
        output_dir=output_dir,
        per_device_train_batch_size=int(hyperparameters.get("per_device_train_batch_size")),
        gradient_accumulation_steps=int(hyperparameters.get("gradient_accumulation_steps")),
        warmup_steps=int(self.default_params.get('warmup_steps', 100)),
        max_steps=int(hyperparameters.get("max_steps")),
        learning_rate=float(hyperparameters.get("learning_rate")),
        fp16=not self.use_bf16,
        bf16=self.use_bf16,
        logging_steps=50,
        save_steps=200,
        eval_steps=200 if 'validation' in formatted_dataset_dict else None,
        optim=self.optimization_config.get("optimizer", "adamw_8bit"),
        lr_scheduler_type=self.optimization_config.get("lr_scheduler_type", "cosine"),
        seed=42,
        save_total_limit=1,
        dataloader_num_workers=self.dataloader_config.get("num_workers", 2),
        gradient_checkpointing=True,
        report_to="none"
    )

    # 创建 SFTTrainer 对象
    trainer = SFTTrainer(
        model=model,
        tokenizer=tokenizer,
        train_dataset=formatted_dataset_dict['train'],
        eval_dataset=formatted_dataset_dict.get('validation'),
        dataset_text_field="text",
        max_seq_length=int(hyperparameters.get("max_seq_length")),
        args=training_args,
        packing=False
    )

    # 运行 SFTTrainer 开始训练
    start_time = time.time()
    trainer = train_on_responses_only(trainer,instruction_part="<|im_start|>user\n",response_part="<|im_start|>assistant\n")
    train_result = unsloth_train(trainer)
    training_time = time.time() - start_time

    # 如果有验证集,就在其上评估模型性能,得到 eval_loss。
    # 如果没有,就直接使用训练结束时的 train_loss 作为本次试验的得分。
    # 这个最终的损失分数会返回给 Optuna,作为其后续决策的依据。
    eval_loss = None
    if 'validation' in formatted_dataset_dict:
        eval_results = trainer.evaluate()
        eval_loss = eval_results.get('eval_loss', None)

    # 保存模型
    trainer.save_model(output_dir)

    # 清理显存
    del trainer, formatted_dataset_dict
    gc.collect()
    torch.cuda.empty_cache()

    return {
        'train_loss': train_result.training_loss,
        'eval_loss': eval_loss,
        'training_time': training_time
    }

至于第四阶段就更加简单了,主要是对模型进行合并工作。它会以全精度(而不是 4-bit)模式重新加载一次基础模型,并加载之前保存的最佳 LoRA 适配器。然后,调用 merge_and_unload 方法将两者的权重合并在一起。

def merge_and_save_for_inference(self, best_adapter_path: str, final_save_dir: str) -> None:
    
    ...
    # 以全精度加载预训练模型的 model 和 tokenizer
    trained_model, trained_tokenizer = FastLanguageModel.from_pretrained(
        model_name=self.base_model,
        dtype=self.compute_dtype,
        load_in_4bit=False,
    )

    logger.info(f"从 {best_adapter_path} 加载最佳LoRA适配器...")
    model = PeftModel.from_pretrained(trained_model, best_adapter_path)
    
    logger.info("正在合并LoRA权重到基础模型中...")
    model = model.merge_and_unload()

    logger.info(f"正在将合并后的模型保存到: {final_save_dir}")
    model.save_pretrained(final_save_dir)
    trained_tokenizer.save_pretrained(final_save_dir)
    logger.info("✓ 推理模型保存成功!")
    ...

合并后的模型和一个配套的分词器(tokenizer)被保存在一个新的目录中。这个模型是完整且独立的,可以被直接加载用于推理服务(这里之所以做好合并工作也是为后面做模型量化输出做准备)。

训练结果

好了,现在让我们执行一下这个自动训练脚本看看效果:

[2025-09-22 10:05:07,065][INFO]-model_auto_turning.py:setup_gpu_optimization - 90 - 检测到 GPU: NVIDIA RTX A6000, 显存: 47.4GB, CUDA 计算能力: 8.6
[2025-09-22 10:05:07,066][INFO]-model_auto_turning.py:setup_gpu_optimization - 92 - 检测到 Ampere 或更高架构的 GPU。启用 TF32 并优先使用 bfloat16。
[2025-09-22 10:05:07,436][INFO]-model_auto_turning.py:load_training_data_from_json - 113 - 加载训练数据集: 35511[2025-09-22 10:05:07,447][INFO]-model_auto_turning.py:load_training_data_from_json - 119 - 加载验证数据集: 3946[I 2025-09-22 10:05:07,651] A new study created in memory with name: no-name-3ffa8d15-de44-423d-a533-59bc3db15e2b
[2025-09-22 10:05:07,652][INFO]-model_auto_turning.py:objective - 255 - 
============================================================
Trial 0/24 - Optuna 建议参数:
[2025-09-22 10:05:07,652][INFO]-model_auto_turning.py:objective - 256 - {
  "learning_rate": "2e-4",
  "max_steps": 800,
  "r": 64,
  "lora_alpha": 128,
  "per_device_train_batch_size": 16,
  "gradient_accumulation_steps": 4,
  "max_seq_length": 8192
}
Unsloth 2025.9.6 patched 28 layers with 28 QKV layers, 28 O layers and 28 MLP layers.
🦥 Unsloth: Will patch your computer to enable 2x faster free finetuning.
🦥 Unsloth Zoo will now patch everything to make training faster!
==((====))==  Unsloth 2025.9.6: Fast Qwen3 patching. Transformers: 4.55.4.
   \\   /|    NVIDIA RTX A6000. Num GPUs = 1. Max memory: 47.431 GB. Platform: Linux.
O^O/ \_/ \    Torch: 2.8.0+cu128. CUDA: 8.6. CUDA Toolkit: 12.8. Triton: 3.4.0
\        /    Bfloat16 = TRUE. FA [Xformers = 0.0.32.post2. FA2 = False]
 "-____-"     Free license: http://github.com/unslothai/unsloth
Unsloth: Fast downloading is enabled - ignore downloading bars which are red colored!
Map (num_proc=12): 100%|██████████| 35511/35511 [00:03<00:00, 9051.50 examples/s] 
Map (num_proc=12): 100%|██████████| 3946/3946 [00:02<00:00, 1590.54 examples/s]
Unsloth: Tokenizing ["text"] (num_proc=28): 100%|██████████| 35511/35511 [00:05<00:00, 6203.23 examples/s]
Unsloth: Tokenizing ["text"] (num_proc=28): 100%|██████████| 3946/3946 [00:04<00:00, 798.39 examples/s] 
Map (num_proc=24): 100%|██████████| 35511/35511 [00:01<00:00, 31027.58 examples/s]
Map (num_proc=24): 100%|██████████| 3946/3946 [00:00<00:00, 7149.67 examples/s]
==((====))==  Unsloth - 2x faster free finetuning | Num GPUs used = 1
   \\   /|    Num examples = 35,511 | Num Epochs = 2 | Total steps = 800
O^O/ \_/ \    Batch size per device = 16 | Gradient accumulation steps = 4
\        /    Data Parallel GPUs = 1 | Total batch size (16 x 4 x 1) = 64
 "-____-"     Trainable parameters = 40,370,176 of 636,420,096 (6.34% trained)

100%|██████████| 800/800 [19:09<00:00,  1.44s/it]
Unsloth: Not an error, but Qwen3ForCausalLM does not accept `num_items_in_batch`.
Using gradient accumulation will be very slightly less accurate.
Read more on gradient accumulation issues here: https://unsloth.ai/blog/gradient
100%|██████████| 494/494 [00:36<00:00, 13.64it/s]
[2025-09-22 10:25:29,074][INFO]-model_auto_turning.py:objective - 294 - Trial 0 完成。得分 (Loss): 2.0352
[I 2025-09-22 10:25:29,377] Trial 0 finished with value: 2.035174608230591 and parameters: {'learning_rate': '2e-4', 'max_steps': 800, 'r': 64, 'lora_alpha': 128, 'per_device_train_batch_size': 16, 'gradient_accumulation_steps': 4, 'max_seq_length': 8192}. Best is trial 0 with value: 2.035174608230591.
[2025-09-22 10:25:29,381][INFO]-model_auto_turning.py:objective - 255 - 
============================================================
Trial 1/24 - Optuna 建议参数:
[2025-09-22 10:25:29,381][INFO]-model_auto_turning.py:objective - 256 - {
  "learning_rate": "3e-4",
  "max_steps": 1500,
  "r": 64,
  "lora_alpha": 32,
  "per_device_train_batch_size": 32,
  "gradient_accumulation_steps": 8,
  "max_seq_length": 8192
}
==((====))==  Unsloth 2025.9.6: Fast Qwen3 patching. Transformers: 4.55.4.
   \\   /|    NVIDIA RTX A6000. Num GPUs = 1. Max memory: 47.431 GB. Platform: Linux.
O^O/ \_/ \    Torch: 2.8.0+cu128. CUDA: 8.6. CUDA Toolkit: 12.8. Triton: 3.4.0
\        /    Bfloat16 = TRUE. FA [Xformers = 0.0.32.post2. FA2 = False]
 "-____-"     Free license: http://github.com/unslothai/unsloth
Unsloth: Fast downloading is enabled - ignore downloading bars which are red colored!

...

[2025-09-23 01:22:31,947][INFO]-model_auto_turning.py:objective - 294 - Trial 23 完成。得分 (Loss): 2.0246
[I 2025-09-23 01:22:32,367] Trial 23 finished with value: 2.0245656967163086 and parameters: {'learning_rate': '3e-4', 'max_steps': 1500, 'r': 32, 'lora_alpha': 64, 'per_device_train_batch_size': 16, 'gradient_accumulation_steps': 4, 'max_seq_length': 8192}. Best is trial 19 with value: 2.0236854553222656.
[2025-09-23 01:22:32,370][INFO]-model_auto_turning.py:model_finetuning - 562 - 
============================================================
智能搜索完成!
[2025-09-23 01:22:32,371][INFO]-model_auto_turning.py:model_finetuning - 563 - 最佳 Trial: #19,得分 (Loss): 2.0237
[2025-09-23 01:22:32,371][INFO]-model_auto_turning.py:model_finetuning - 564 - 最佳超参数: {
  "learning_rate": "3e-4",
  "max_steps": 1500,
  "r": 32,
  "lora_alpha": 64,
  "per_device_train_batch_size": 16,
  "gradient_accumulation_steps": 4,
  "max_seq_length": 8192
}
[2025-09-23 01:22:33,073][INFO]-model_auto_turning.py:save_comprehensive_report - 335 - 正在生成最终的训练报告...
[2025-09-23 01:22:33,077][INFO]-model_auto_turning.py:save_comprehensive_report - 424 - ✓ 详细的训练报告已保存至: /home/llm/modelscope/Qwen/0.6B/FinalBestModel
[2025-09-23 01:22:33,077][INFO]-model_auto_turning.py:model_finetuning - 580 - 
✓ 最佳LoRA适配器已保存至: /home/llm/modelscope/Qwen/0.6B/FinalBestModel
[2025-09-23 01:22:33,077][INFO]-model_auto_turning.py:merge_and_save_for_inference - 494 - 
============================================================
[2025-09-23 01:22:33,077][INFO]-model_auto_turning.py:merge_and_save_for_inference - 495 - 开始合并模型以用于推理...
[2025-09-23 01:22:38,793][INFO]-model_auto_turning.py:merge_and_save_for_inference - 510 - 从 /home/llm/modelscope/Qwen/0.6B/FinalBestModel 加载最佳LoRA适配器...
[2025-09-23 01:22:39,269][INFO]-model_auto_turning.py:merge_and_save_for_inference - 513 - 正在合并LoRA权重到基础模型中...
[2025-09-23 01:22:39,381][INFO]-model_auto_turning.py:merge_and_save_for_inference - 517 - 正在将合并后的模型保存到: /home/llm/modelscope/Qwen/0.6B/FinalBestModelMerged
[2025-09-23 01:22:41,116][INFO]-model_auto_turning.py:merge_and_save_for_inference - 520 - ✓ 推理模型保存成功!

日志输出正常,可以看到系统已经自动选择了第 19 轮训练结果为最佳结果。为了更直观看到训练结果我们可以打开名为“training_report.md”的 markdown 文档。这个文档会记录下每轮训练的关键数据,内容如下:

智能超参数优化训练报告

1. 训练概览

  • 生成时间: 2025-09-23 01:22:33
  • 基础模型: /home/llm/modelscope/Qwen/0.6B
  • 请求试验次数: 24
  • 成功完成次数: 24
  • 总训练耗时: 14.80 hours

2. 最佳模型详情

  • 最佳试验编号: Trial #19
  • 最终得分 (Loss): 2.0237
  • 验证集损失 (Eval Loss): 2.0237
  • 训练集损失 (Train Loss): 1.8384
  • 单次训练耗时: 36.2 分钟

最佳超参数组合

{
  "learning_rate": "3e-4",
  "max_steps": 1500,
  "r": 32,
  "lora_alpha": 64,
  "per_device_train_batch_size": 16,
  "gradient_accumulation_steps": 4,
  "max_seq_length": 8192
}

3. 统计数据摘要

指标最小值最大值平均值标准差
验证集损失2.02372.58822.09950.1262
训练集损失1.40402.32191.92960.2101
训练耗时(分钟)8.2137.937.0-

4. 所有试验详情 (按性能排序)

排名Trial #得分 (Loss)验证集 Loss训练集 Loss批次大小学习率LoRA Rank (r)序列长度
1192.02372.02371.8384163e-4328192
2162.02402.02401.8384163e-4328192
3212.02412.02411.8384163e-4328192
4182.02462.02461.8386163e-4328192
5232.02462.02461.8386163e-4328192
6222.02482.02481.8385163e-4328192
7152.03062.03061.8121162e-4328192
8142.03102.03101.8119162e-4328192
9172.03352.03352.0421163e-4328192
10202.03412.03412.0420163e-4328192
1102.03522.03522.0155162e-4648192
12132.05002.05002.0300162e-4164096
13122.05012.05012.0306162e-4164096
1422.05042.05042.030882e-4164096
15112.05042.05042.030882e-4164096
1682.07542.07542.0204322e-4328192
1772.08852.08851.9641325e-5328192
1862.11692.11691.6533162e-4644096
1952.12442.12442.1320161e-4648192
2032.16452.16452.214981e-4644096
21102.17552.17552.2225165e-5648192
2292.21342.21342.321982e-4164096
2342.32982.32981.5003323e-4168192
2412.58822.58821.4040323e-4648192

结论

至此,brain-mix 项目已具备自动模型训练能力。由于已能根据训练结果自动迭代优化,因此接下来只需要关注训练超参数以及进一步提炼训练数据即可,在保证数据足够好且训练时长足够的情况下,打造一个理想的垂直模型并不困难(国庆长假就是一个好时机...闭着眼训练即可)。

以上代码均发布到 brain-mix 项目中,欢迎各位的指导。

gitee: gitee.com/yzh0623/bra…

github:github.com/yzh0623/bra…

下一章将继续讲解模型量化处理,敬请留意。

(未完待续...)