mirror of
https://github.com/hiyouga/LLaMA-Factory.git
synced 2026-02-26 07:45:59 +08:00
157 lines
5.6 KiB
Python
157 lines
5.6 KiB
Python
# Copyright 2025 the LlamaFactory team.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import pytest
|
|
from peft import LoraConfig, PeftModel, get_peft_model
|
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
|
|
from llamafactory.v1.plugins.model_plugins import peft as peft_module
|
|
from llamafactory.v1.plugins.model_plugins.peft import merge_and_export_model
|
|
|
|
|
|
TINY_MODEL = "llamafactory/tiny-random-qwen3"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def model_path():
|
|
return TINY_MODEL
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def model(model_path):
|
|
return AutoModelForCausalLM.from_pretrained(model_path)
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def tokenizer(model_path):
|
|
return AutoTokenizer.from_pretrained(model_path)
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def adapter_path(tmp_path):
|
|
# Create a dummy adapter
|
|
lora_config = LoraConfig(
|
|
r=8,
|
|
lora_alpha=16,
|
|
target_modules=["q_proj", "v_proj"],
|
|
lora_dropout=0.05,
|
|
bias="none",
|
|
task_type="CAUSAL_LM",
|
|
)
|
|
|
|
base_model = AutoModelForCausalLM.from_pretrained(TINY_MODEL)
|
|
peft_model = get_peft_model(base_model, lora_config)
|
|
save_path = tmp_path / "test_adapter"
|
|
peft_model.save_pretrained(save_path)
|
|
return str(save_path)
|
|
|
|
|
|
def test_find_all_linear_modules(model):
|
|
"""Verify linear modules are discoverable and include q_proj / v_proj for tiny-random-qwen3."""
|
|
modules = peft_module._find_all_linear_modules(model)
|
|
expected_subset = {"q_proj", "v_proj"}
|
|
assert expected_subset.issubset(set(modules))
|
|
|
|
|
|
def test_get_lora_model(model):
|
|
"""Verify a PeftModel is returned and LoRA config takes effect."""
|
|
config = {"name": "lora", "r": 8, "target_modules": "all", "lora_alpha": 16}
|
|
model = peft_module.get_lora_model(model, config, is_train=True)
|
|
assert isinstance(model, PeftModel)
|
|
assert model.peft_config["default"].r == 8
|
|
assert "q_proj" in model.peft_config["default"].target_modules
|
|
|
|
|
|
def test_get_freeze_model_layers(model):
|
|
"""Verify layer-wise freezing: only the last layer stays trainable."""
|
|
# Freeze all but last layer
|
|
config = {"name": "freeze", "freeze_trainable_layers": 1, "freeze_trainable_modules": "all"}
|
|
|
|
# Ensure we start with something known
|
|
model = peft_module.get_freeze_model(model, config, is_train=True)
|
|
|
|
num_layers = model.config.num_hidden_layers
|
|
assert num_layers > 0
|
|
|
|
for name, param in model.named_parameters():
|
|
if f"layers.{num_layers - 1}" in name:
|
|
assert param.requires_grad, f"{name} should be trainable"
|
|
elif "layers.0" in name and num_layers > 1:
|
|
assert not param.requires_grad, f"{name} should be frozen"
|
|
|
|
|
|
def test_get_freeze_model_modules(model):
|
|
"""Verify module-wise freezing: only last-layer self_attn is trainable."""
|
|
# Freeze specific modules (e.g. only self_attn)
|
|
config = {"name": "freeze", "freeze_trainable_layers": 1, "freeze_trainable_modules": "self_attn"}
|
|
model = peft_module.get_freeze_model(model, config, is_train=True)
|
|
|
|
num_layers = model.config.num_hidden_layers
|
|
|
|
for name, param in model.named_parameters():
|
|
if f"layers.{num_layers - 1}" in name and "self_attn" in name:
|
|
assert param.requires_grad, f"{name} should be trainable"
|
|
else:
|
|
assert not param.requires_grad, f"{name} should be frozen"
|
|
|
|
|
|
def test_load_adapter_single_for_inference(model, adapter_path):
|
|
"""Verify single adapter is merged+unloaded in inference mode."""
|
|
# Test loading single adapter for inference (merge and unload)
|
|
model_result = peft_module.load_adapter(model, adapter_path, is_train=False)
|
|
assert not isinstance(model_result, PeftModel)
|
|
|
|
|
|
def test_load_adapter_resume_train(model, adapter_path):
|
|
"""Verify training mode returns a trainable PeftModel."""
|
|
# Test loading for training
|
|
model_result = peft_module.load_adapter(model, adapter_path, is_train=True)
|
|
assert isinstance(model_result, PeftModel)
|
|
|
|
|
|
def test_load_adapter_train_multiple_disallowed(model, adapter_path):
|
|
"""Verify multiple adapters are rejected in training mode."""
|
|
with pytest.raises(ValueError, match="only a single LoRA adapter"):
|
|
peft_module.load_adapter(model, [adapter_path, adapter_path], is_train=True)
|
|
|
|
|
|
def test_load_adapter_infer_multiple_merges(model, adapter_path):
|
|
"""Verify multiple adapters are merged in inference mode."""
|
|
# Test merging multiple adapters
|
|
model_result = peft_module.load_adapter(model, [adapter_path, adapter_path], is_train=False)
|
|
assert not isinstance(model_result, PeftModel)
|
|
|
|
|
|
def test_merge_and_export_model(tmp_path, adapter_path):
|
|
"""Verify merge_and_export_model produces export artifacts."""
|
|
export_dir = tmp_path / "export"
|
|
|
|
args_dict = {
|
|
"model": TINY_MODEL,
|
|
"peft_config": {
|
|
"name": "lora",
|
|
"adapter_name_or_path": adapter_path,
|
|
"export_dir": str(export_dir),
|
|
"export_size": 1,
|
|
"infer_dtype": "float16",
|
|
},
|
|
}
|
|
|
|
merge_and_export_model(args_dict)
|
|
|
|
assert export_dir.exists()
|
|
assert (export_dir / "config.json").exists()
|
|
assert (export_dir / "model.safetensors").exists()
|
|
assert (export_dir / "tokenizer_config.json").exists()
|