ai-toolkit/testing/test_sd_trainer_three_phase...

261 lines
11 KiB
Python

import ast
import contextlib
import importlib
import inspect
import os
import tempfile
import types
import unittest
from pathlib import Path
from types import MethodType, SimpleNamespace
from unittest.mock import patch
import torch
def _load_runtime_methods():
source_path = Path(__file__).parents[1] / 'extensions_built_in' / 'sd_trainer' / 'SDTrainer.py'
tree = ast.parse(source_path.read_text(encoding='utf-8'))
class_node = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'SDTrainer')
names = {
'three_phase_enabled', '_load_trigger_binding_modules', '_call_supported', '_first_callable',
'_phase_config', '_activator_component_flags', '_configure_phase_trainability',
'hook_add_extra_train_params', '_activator_mode', '_write_trigger_binding_metrics',
'_calculate_trigger_binding_loss', '_install_trigger_binding_prompt_encoder', 'encode_static_prompt',
}
selected = [node for node in class_node.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names]
module = ast.Module(body=[ast.ClassDef(name='SDTrainerRuntimeHarness', bases=[], keywords=[], body=selected, decorator_list=[])], type_ignores=[])
ast.fix_missing_locations(module)
namespace = {
'contextlib': contextlib,
'importlib': importlib,
'inspect': inspect,
'os': os,
'MethodType': MethodType,
'torch': torch,
'get_torch_dtype': lambda _dtype: torch.float32,
'shared_loss_target': lambda trainer, noise, batch, timesteps: trainer.sd.get_loss_target(
noise=noise, batch=batch, timesteps=timesteps
).detach(),
}
exec(compile(module, str(source_path), 'exec'), namespace)
return namespace['SDTrainerRuntimeHarness']
SDTrainer = _load_runtime_methods()
class _FakeActivator(torch.nn.Module):
def __init__(self):
super().__init__()
self.embedding = torch.nn.Linear(2, 2, bias=False)
self.te_adapter = torch.nn.Linear(2, 2, bias=False)
self.tap_adapters = torch.nn.Linear(2, 2, bias=False)
self.component_active = {}
def set_component_mode(self, component, active=None, trainable=None):
self.component_active[component] = active
getattr(self, component).requires_grad_(trainable)
def parameter_groups(self, learning_rates=None):
learning_rates = learning_rates or {}
groups = []
for name in ('embedding', 'te_adapter', 'tap_adapters'):
params = [parameter for parameter in getattr(self, name).parameters() if parameter.requires_grad]
if params:
group = {'params': params, 'name': name}
if name in learning_rates:
group['lr'] = learning_rates[name]
groups.append(group)
return groups
class ThreePhaseRuntimeTest(unittest.TestCase):
def _trainer(self, phase):
trainer = SDTrainer.__new__(SDTrainer)
trainer.runtime_phase = phase
trainer.text_activator = _FakeActivator()
trainer.network = torch.nn.Linear(2, 2, bias=False)
phase_config = SimpleNamespace(
train={
'embedding': phase != 'b',
'internal': False,
'tap': phase == 'a2',
'diffusion_lora': phase == 'b',
},
learning_rates={'embedding': 1e-3, 'tap_adapters': 2e-3},
)
trainer.three_phase_trigger_training = SimpleNamespace(
enabled=True,
phase_a1=phase_config,
phase_b=phase_config,
phase_a2=phase_config,
)
return trainer
def test_phase_whitelist_freezes_non_targets(self):
trainer = self._trainer('a2')
params = [{'params': list(trainer.network.parameters())}]
filtered = trainer.hook_add_extra_train_params(params)
selected = {id(parameter) for group in filtered for parameter in group['params']}
self.assertTrue(all(id(parameter) not in selected for parameter in trainer.network.parameters()))
self.assertTrue(all(parameter.requires_grad for parameter in trainer.text_activator.embedding.parameters()))
self.assertTrue(all(parameter.requires_grad for parameter in trainer.text_activator.tap_adapters.parameters()))
self.assertTrue(all(not parameter.requires_grad for parameter in trainer.text_activator.te_adapter.parameters()))
def test_b_phase_keeps_only_diffusion_lora_and_freezes_activator(self):
trainer = self._trainer('b')
params = [{'params': list(trainer.network.parameters()) + list(trainer.text_activator.parameters())}]
filtered = trainer.hook_add_extra_train_params(params)
selected = {id(parameter) for group in filtered for parameter in group['params']}
self.assertEqual(selected, {id(parameter) for parameter in trainer.network.parameters()})
self.assertTrue(all(not parameter.requires_grad for parameter in trainer.text_activator.parameters()))
def test_static_prompt_bypasses_required_trigger_binding(self):
trainer = self._trainer('a1')
modes = []
class _ModeContext:
def __init__(self, mode):
self.mode = mode
def __enter__(self):
modes.append(self.mode)
def __exit__(self, *_args):
modes.append('restored')
trainer._activator_mode = lambda mode: _ModeContext(mode)
trainer.sd = SimpleNamespace(
encode_prompt=lambda prompt, **_kwargs: ('encoded', prompt),
)
result = trainer.encode_static_prompt([''])
self.assertEqual(result, ('encoded', ['']))
self.assertEqual(modes, ['activator_bypass', 'restored'])
def test_prompt_encoder_allows_static_prompt_only_in_bypass_mode(self):
trainer = self._trainer('a1')
original_calls = []
class _SD:
text_activator_runtime_mode = 'activator_bypass'
def get_prompt_embeds(self, prompt, **kwargs):
original_calls.append((prompt, kwargs))
return ('plain', prompt)
trainer.sd = _SD()
trainer.three_phase_trigger_training.literal = '<trigger>'
trainer.three_phase_trigger_training.placeholder = '[trigger]'
trainer.three_phase_trigger_training.mask_all_occurrences = True
trainer._install_trigger_binding_prompt_encoder(SimpleNamespace())
result = trainer.sd.get_prompt_embeds([''])
self.assertEqual(result, ('plain', ['']))
self.assertEqual(original_calls[0][1]['runtime_mode'], 'activator_bypass')
def test_prompt_encoder_bypasses_already_injected_literal_caption(self):
trainer = self._trainer('a1')
original_calls = []
class _SD:
text_activator_runtime_mode = 'full'
def get_prompt_embeds(self, prompt, **kwargs):
original_calls.append((prompt, kwargs))
return ('plain', prompt)
trainer.sd = _SD()
trainer.three_phase_trigger_training.literal = '<trigger>'
trainer.three_phase_trigger_training.placeholder = '[trigger]'
trainer.three_phase_trigger_training.mask_all_occurrences = True
trainer._install_trigger_binding_prompt_encoder(SimpleNamespace())
result = trainer.sd.get_prompt_embeds(['caption with <trigger> already injected'])
self.assertEqual(result, ('plain', ['caption with <trigger> already injected']))
self.assertEqual(original_calls[0][1]['runtime_mode'], 'activator_bypass')
def test_prompt_encoder_still_rejects_caption_without_placeholder_or_literal(self):
trainer = self._trainer('a1')
class _SD:
text_activator_runtime_mode = 'full'
def get_prompt_embeds(self, prompt, **kwargs):
return ('plain', prompt, kwargs)
trainer.sd = _SD()
trainer.three_phase_trigger_training.literal = '<trigger>'
trainer.three_phase_trigger_training.placeholder = '[trigger]'
trainer.three_phase_trigger_training.mask_all_occurrences = True
trainer._install_trigger_binding_prompt_encoder(SimpleNamespace())
with self.assertRaisesRegex(ValueError, 'every training caption must contain'):
trainer.sd.get_prompt_embeds(['caption without the required token'])
def test_phase_metrics_are_written_independently_and_once_per_step(self):
trainer = self._trainer('a1')
trainer.step_num = 7
trainer._trigger_binding_last_metrics = {'gain': 0.25}
trainer._trigger_binding_last_metrics_written_step = None
with tempfile.TemporaryDirectory() as temp_dir:
trainer.save_root = temp_dir
trainer.three_phase_trigger_training.run_root = temp_dir
trainer.three_phase_trigger_training.artifacts = SimpleNamespace(
phase_a1=SimpleNamespace(metrics_file='metrics.jsonl'),
)
trainer._write_trigger_binding_metrics(torch.tensor(0.5))
trainer._write_trigger_binding_metrics(torch.tensor(0.75))
metrics_path = Path(temp_dir) / 'phase_a1' / 'metrics.jsonl'
records = metrics_path.read_text(encoding='utf-8').splitlines()
self.assertEqual(len(records), 1)
self.assertIn('"step": 7', records[0])
self.assertIn('"loss": 0.5', records[0])
self.assertIn('"gain": 0.25', records[0])
def test_a_phase_loss_receives_shared_latent_noise_timestep_and_target(self):
trainer = self._trainer('a1')
trainer.device_torch = torch.device('cpu')
trainer.do_long_prompts = False
trainer.additional_logs = {}
trainer.sd = SimpleNamespace(
encode_prompt=lambda prompts, **kwargs: torch.ones(len(prompts), 1, 2),
get_loss_target=lambda noise, batch, timesteps: noise + 1,
)
trainer.predict_noise = lambda noisy_latents, **kwargs: noisy_latents * trainer.text_activator.embedding.weight.mean()
trainer._activator_mode = lambda mode: patch.object(trainer, '_mode', mode, create=True)
batch = SimpleNamespace(
file_items=[SimpleNamespace(caption_template='x [trigger]', raw_caption='unused')],
latents=torch.zeros(1, 2),
)
noisy = torch.randn(1, 2)
noise = torch.randn(1, 2)
timesteps = torch.tensor([10])
captured = {}
def fake_losses(**kwargs):
captured.update(kwargs)
return {'loss': (kwargs['active_prediction'] - kwargs['target']).pow(2).mean(), 'metrics': {'paired': 1}}
trainer._trigger_binding_modules = {
'losses': types.SimpleNamespace(calculate_trigger_binding_losses=fake_losses)
}
trainer._write_trigger_binding_metrics = lambda _loss: None
loss = trainer._calculate_trigger_binding_loss(
noisy, noise, timesteps, batch, {}, 1.0, torch.float32
)
self.assertTrue(loss.requires_grad)
self.assertIs(captured['noisy_latents'], noisy)
self.assertIs(captured['noise'], noise)
self.assertIs(captured['timesteps'], timesteps)
self.assertTrue(torch.equal(captured['target'], noise + 1))
self.assertEqual(trainer.additional_logs['phase/a1/paired'], 1.0)
if __name__ == '__main__':
unittest.main()