""" 训练模块单元测试 测试训练模块的所有功能,包括: - QLoRA 配置 - LoRA 配置 - 数据集加载和处理 - 模型合并 - 训练流程 使用 mock 避免实际训练和模型加载。 """ import os import sys import pytest from pathlib import Path from unittest.mock import MagicMock, patch, Mock import json # 添加项目路径 sys.path.insert(0, str(Path(__file__).parent.parent)) from hos_optimizer.train import ( TrainingConfig, DatasetProcessor, load_and_process_dataset, get_quantization_config, load_model_and_tokenizer, get_lora_config, prepare_model_for_training, get_training_arguments, VRAMCallback, train, merge_model, ) class TestTrainingConfig: """训练配置测试""" def test_default_config(self): """测试默认配置""" config = TrainingConfig() assert config.model_name_or_path == "Qwen/Qwen2.5-0.5B" assert config.finetuning_type == "qlora" assert config.use_4bit is True assert config.lora_rank == 16 assert config.lora_alpha == 32 assert config.num_train_epochs == 3 assert config.learning_rate == 2e-4 def test_custom_config(self): """测试自定义配置""" config = TrainingConfig( model_name_or_path="custom/model", finetuning_type="lora", use_4bit=False, lora_rank=32, lora_alpha=64, num_train_epochs=5, learning_rate=1e-4 ) assert config.model_name_or_path == "custom/model" assert config.finetuning_type == "lora" assert config.use_4bit is False assert config.lora_rank == 32 assert config.lora_alpha == 64 assert config.num_train_epochs == 5 assert config.learning_rate == 1e-4 class TestDatasetProcessor: """数据集处理器测试""" def test_format_alpaca_with_input(self): """测试 Alpaca 格式带输入的处理""" mock_tokenizer = MagicMock() processor = DatasetProcessor(mock_tokenizer, max_seq_length=512) example = { "instruction": "什么是网络安全?", "input": "请举例说明", "output": "网络安全是指保护计算机网络免受未经授权的访问。" } result = processor.format_alpaca(example) assert "### 指令:" in result["text"] assert "什么是网络安全?" in result["text"] assert "### 输入:" in result["text"] assert "请举例说明" in result["text"] assert "### 回答:" in result["text"] assert "保护计算机网络" in result["text"] def test_format_alpaca_without_input(self): """测试 Alpaca 格式不带输入的处理""" mock_tokenizer = MagicMock() processor = DatasetProcessor(mock_tokenizer, max_seq_length=512) example = { "instruction": "什么是网络安全?", "input": "", "output": "网络安全是指保护计算机网络。" } result = processor.format_alpaca(example) assert "### 指令:" in result["text"] assert "什么是网络安全?" in result["text"] assert "### 输入:" not in result["text"] assert "### 回答:" in result["text"] def test_format_sharegpt(self): """测试 ShareGPT 格式的处理""" mock_tokenizer = MagicMock() processor = DatasetProcessor(mock_tokenizer, max_seq_length=512) example = { "conversations": [ {"from": "human", "value": "你好"}, {"from": "gpt", "value": "你好!有什么可以帮助你的吗?"}, ] } result = processor.format_sharegpt(example) assert "### 用户:" in result["text"] assert "你好" in result["text"] assert "### 助手:" in result["text"] assert "帮助你的吗" in result["text"] def test_tokenize_function(self): """测试分词函数""" mock_tokenizer = MagicMock() mock_tokenizer.return_value = { "input_ids": [1, 2, 3, 4, 5], "attention_mask": [1, 1, 1, 1, 1] } processor = DatasetProcessor(mock_tokenizer, max_seq_length=512) example = {"text": "测试文本"} result = processor.tokenize_function(example) assert "input_ids" in result assert "attention_mask" in result assert "labels" in result assert result["labels"] == result["input_ids"] def test_process_dataset_alpaca(self): """测试处理 Alpaca 格式数据集""" mock_tokenizer = MagicMock() mock_tokenizer.return_value = { "input_ids": [1, 2, 3], "attention_mask": [1, 1, 1] } mock_tokenizer.num_proc = 4 processor = DatasetProcessor(mock_tokenizer, max_seq_length=512) mock_dataset = MagicMock() mock_dataset.__len__.return_value = 10 mock_dataset.column_names = ["instruction", "input", "output"] # Mock map 方法 def mock_map(func, **kwargs): return mock_dataset mock_dataset.map = mock_map result = processor.process_dataset(mock_dataset, dataset_format="alpaca") assert result is not None def test_process_dataset_invalid_format(self): """测试处理无效格式数据集""" mock_tokenizer = MagicMock() processor = DatasetProcessor(mock_tokenizer, max_seq_length=512) mock_dataset = MagicMock() with pytest.raises(ValueError) as exc_info: processor.process_dataset(mock_dataset, dataset_format="invalid_format") assert "不支持的数据格式" in str(exc_info.value) class TestLoadAndProcessDataset: """数据集加载和处理测试""" def test_load_dataset_file_not_found(self): """测试加载不存在的数据集文件""" mock_tokenizer = MagicMock() with pytest.raises(FileNotFoundError) as exc_info: load_and_process_dataset( dataset_path="/nonexistent/dataset.json", tokenizer=mock_tokenizer ) assert "数据集文件不存在" in str(exc_info.value) def test_load_dataset_success(self, tmp_dir): """测试成功加载数据集""" # 创建测试数据集 dataset_path = os.path.join(tmp_dir, "dataset.json") data = [ { "instruction": "测试指令", "input": "", "output": "测试输出" } ] with open(dataset_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False) mock_tokenizer = MagicMock() mock_tokenizer.return_value = { "input_ids": [1, 2, 3], "attention_mask": [1, 1, 1] } mock_tokenizer.num_proc = 4 with patch("hos_optimizer.train.load_dataset") as mock_load: mock_dataset = MagicMock() mock_dataset.__len__.return_value = 10 mock_dataset.column_names = ["instruction", "input", "output"] mock_dataset.map.return_value = mock_dataset mock_dataset.train_test_split.return_value = { "train": MagicMock(__len__=MagicMock(return_value=9)), "test": MagicMock(__len__=MagicMock(return_value=1)) } mock_load.return_value = mock_dataset result = load_and_process_dataset( dataset_path=dataset_path, tokenizer=mock_tokenizer, dataset_format="alpaca", max_seq_length=512, test_size=0.1 ) assert "train" in result assert "test" in result class TestQuantizationConfig: """量化配置测试""" def test_get_quantization_config_disabled(self): """测试禁用量化配置""" config = TrainingConfig(use_4bit=False) result = get_quantization_config(config) assert result is None def test_get_quantization_config_enabled(self): """测试启用量化配置""" config = TrainingConfig( use_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype="bfloat16" ) with patch("hos_optimizer.train.BitsAndBytesConfig") as mock_bnb: mock_config = MagicMock() mock_bnb.return_value = mock_config result = get_quantization_config(config) assert result is not None mock_bnb.assert_called_once() class TestLoadModelAndTokenizer: """模型和分词器加载测试""" def test_load_model_standard(self): """测试标准模型加载""" config = TrainingConfig( model_name_or_path="test/model", use_unsloth=False, use_4bit=False ) with patch("hos_optimizer.train.AutoTokenizer") as mock_tokenizer_cls, \ patch("hos_optimizer.train.AutoModelForCausalLM") as mock_model_cls: mock_tokenizer = MagicMock() mock_tokenizer.pad_token = None mock_tokenizer.eos_token = "" mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer mock_model = MagicMock() mock_model.num_parameters.return_value = 1e9 mock_model_cls.from_pretrained.return_value = mock_model model, tokenizer = load_model_and_tokenizer(config) assert model is not None assert tokenizer is not None assert tokenizer.pad_token == "" def test_load_model_with_unsloth(self): """测试使用 Unsloth 加载模型""" config = TrainingConfig( model_name_or_path="test/model", use_unsloth=True, finetuning_type="qlora" ) with patch("hos_optimizer.train.UNSLOTH_AVAILABLE", True), \ patch("hos_optimizer.train.FastLanguageModel") as mock_fast: mock_model = MagicMock() mock_tokenizer = MagicMock() mock_fast.from_pretrained.return_value = (mock_model, mock_tokenizer) model, tokenizer = load_model_and_tokenizer(config) assert model is not None assert tokenizer is not None mock_fast.from_pretrained.assert_called_once() def test_load_model_with_quantization(self): """测试带量化的模型加载""" config = TrainingConfig( model_name_or_path="test/model", use_unsloth=False, use_4bit=True ) with patch("hos_optimizer.train.get_quantization_config") as mock_get_quant, \ patch("hos_optimizer.train.AutoTokenizer") as mock_tokenizer_cls, \ patch("hos_optimizer.train.AutoModelForCausalLM") as mock_model_cls: mock_quant_config = MagicMock() mock_get_quant.return_value = mock_quant_config mock_tokenizer = MagicMock() mock_tokenizer.pad_token = "" mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer mock_model = MagicMock() mock_model.num_parameters.return_value = 1e9 mock_model_cls.from_pretrained.return_value = mock_model model, tokenizer = load_model_and_tokenizer(config) assert model is not None # 验证量化配置被传递 call_kwargs = mock_model_cls.from_pretrained.call_args[1] assert "quantization_config" in call_kwargs class TestLoraConfig: """LoRA 配置测试""" def test_get_lora_config_all_modules(self): """测试所有模块的 LoRA 配置""" config = TrainingConfig( lora_rank=16, lora_alpha=32, lora_dropout=0.05, lora_target_modules=["all"] ) with patch("hos_optimizer.train.LoraConfig") as mock_lora: mock_config = MagicMock() mock_lora.return_value = mock_config result = get_lora_config(config) assert result is not None mock_lora.assert_called_once() # 验证 target_modules 为 None(表示所有模块) call_kwargs = mock_lora.call_args[1] assert call_kwargs["target_modules"] is None def test_get_lora_config_specific_modules(self): """测试特定模块的 LoRA 配置""" config = TrainingConfig( lora_rank=16, lora_alpha=32, lora_dropout=0.05, lora_target_modules=["q_proj", "v_proj"] ) with patch("hos_optimizer.train.LoraConfig") as mock_lora: mock_config = MagicMock() mock_lora.return_value = mock_config result = get_lora_config(config) assert result is not None call_kwargs = mock_lora.call_args[1] assert call_kwargs["target_modules"] == ["q_proj", "v_proj"] class TestPrepareModelForTraining: """模型训练准备测试""" def test_prepare_model_qlora(self): """测试 QLoRA 模式的模型准备""" config = TrainingConfig( use_4bit=True, finetuning_type="qlora" ) mock_model = MagicMock() with patch("hos_optimizer.train.prepare_model_for_kbit_training") as mock_prepare, \ patch("hos_optimizer.train.get_lora_config") as mock_get_lora, \ patch("hos_optimizer.train.get_peft_model") as mock_get_peft: mock_prepare.return_value = mock_model mock_lora_config = MagicMock() mock_get_lora.return_value = mock_lora_config mock_get_peft.return_value = mock_model result = prepare_model_for_training(mock_model, config) assert result is not None mock_prepare.assert_called_once() mock_get_peft.assert_called_once() def test_prepare_model_lora(self): """测试 LoRA 模式的模型准备""" config = TrainingConfig( use_4bit=False, finetuning_type="lora" ) mock_model = MagicMock() with patch("hos_optimizer.train.get_lora_config") as mock_get_lora, \ patch("hos_optimizer.train.get_peft_model") as mock_get_peft: mock_lora_config = MagicMock() mock_get_lora.return_value = mock_lora_config mock_get_peft.return_value = mock_model result = prepare_model_for_training(mock_model, config) assert result is not None mock_get_peft.assert_called_once() class TestTrainingArguments: """训练参数测试""" def test_get_training_arguments(self): """测试获取训练参数""" config = TrainingConfig( output_dir="./test_output", num_train_epochs=5, per_device_train_batch_size=4, learning_rate=1e-4 ) with patch("hos_optimizer.train.TrainingArguments") as mock_args: mock_training_args = MagicMock() mock_args.return_value = mock_training_args result = get_training_arguments(config) assert result is not None mock_args.assert_called_once() class TestVRAMCallback: """VRAM 回调测试""" def test_vram_callback_on_log(self): """测试 VRAM 回调的日志记录""" callback = VRAMCallback() mock_args = MagicMock() mock_state = MagicMock() mock_state.global_step = 100 mock_state.is_world_process_zero = True mock_control = MagicMock() logs = {} with patch("torch.cuda.is_available", return_value=True), \ patch("torch.cuda.max_memory_allocated", return_value=4 * 1024 ** 3), \ patch("torch.cuda.memory_reserved", return_value=6 * 1024 ** 3): callback.on_log(mock_args, mock_state, mock_control, logs=logs) assert "gpu_memory_gb" in logs assert "gpu_memory_reserved_gb" in logs class TestTrain: """训练流程测试""" def test_train_success(self, tmp_dir): """测试成功训练流程""" # 创建测试数据集 dataset_path = os.path.join(tmp_dir, "dataset.json") data = [{"instruction": "测试", "input": "", "output": "输出"}] with open(dataset_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False) config = TrainingConfig( model_name_or_path="test/model", dataset_path=dataset_path, output_dir=os.path.join(tmp_dir, "output") ) with patch("hos_optimizer.train.load_model_and_tokenizer") as mock_load, \ patch("hos_optimizer.train.load_and_process_dataset") as mock_load_dataset, \ patch("hos_optimizer.train.prepare_model_for_training") as mock_prepare, \ patch("hos_optimizer.train.get_training_arguments") as mock_get_args, \ patch("hos_optimizer.train.Trainer") as mock_trainer_cls, \ patch("hos_optimizer.train.DataCollatorForSeq2Seq"): mock_model = MagicMock() mock_tokenizer = MagicMock() mock_load.return_value = (mock_model, mock_tokenizer) mock_dataset_dict = { "train": MagicMock(), "test": MagicMock() } mock_load_dataset.return_value = mock_dataset_dict mock_prepare.return_value = mock_model mock_training_args = MagicMock() mock_get_args.return_value = mock_training_args mock_trainer = MagicMock() mock_train_result = MagicMock() mock_train_result.training_loss = 0.5 mock_train_result.metrics = {"train_runtime": 100.0} mock_trainer.train.return_value = mock_train_result mock_trainer_cls.return_value = mock_trainer train(config) mock_trainer.train.assert_called_once() mock_trainer.save_model.assert_called_once() class TestMergeModel: """模型合并测试""" def test_merge_model_success(self, tmp_dir): """测试成功合并模型""" base_model_path = "/path/to/base" adapter_path = "/path/to/adapter" output_path = os.path.join(tmp_dir, "merged") with patch("hos_optimizer.train.AutoModelForCausalLM") as mock_model_cls, \ patch("hos_optimizer.train.PeftModel") as mock_peft, \ patch("hos_optimizer.train.AutoTokenizer") as mock_tokenizer_cls, \ patch("os.makedirs"): mock_base_model = MagicMock() mock_model_cls.from_pretrained.return_value = mock_base_model mock_peft_model = MagicMock() mock_merged_model = MagicMock() mock_peft.from_pretrained.return_value = mock_peft_model mock_peft_model.merge_and_unload.return_value = mock_merged_model mock_tokenizer = MagicMock() mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer merge_model( base_model_path=base_model_path, adapter_path=adapter_path, output_path=output_path ) mock_peft_model.merge_and_unload.assert_called_once() mock_merged_model.save_pretrained.assert_called_once_with(output_path) mock_tokenizer.save_pretrained.assert_called_once_with(output_path) if __name__ == "__main__": pytest.main([__file__, "-v"])