Files
roboimi/tests/test_train_vla_transformer_optimizer.py
T
2026-07-31 10:11:04 +08:00

603 lines
21 KiB
Python

import importlib.util
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest import mock
import torch
from torch import nn
_REPO_ROOT = Path(__file__).resolve().parents[1]
_TRAIN_VLA_PATH = _REPO_ROOT / 'roboimi/demos/vla_scripts/train_vla.py'
class AttrDict(dict):
def __getattr__(self, name):
try:
return self[name]
except KeyError as exc:
raise AttributeError(name) from exc
def __setattr__(self, name, value):
self[name] = value
class FakeDataset:
def __len__(self):
return 4
class FakeLoader:
def __len__(self):
return 1
def __iter__(self):
return iter(())
class FakeTqdm:
def __init__(self, iterable, **_kwargs):
self.iterable = iterable
def __iter__(self):
return iter(self.iterable)
def set_postfix(self, *_args, **_kwargs):
return None
class FakeScheduler:
def state_dict(self):
return {}
def load_state_dict(self, state_dict):
return None
def step(self):
return None
class RecordingAdamW:
created = []
def __init__(self, params, lr, weight_decay, betas=(0.9, 0.999), eps=1e-8):
self.lr = lr
self.weight_decay = weight_decay
self.betas = betas
self.eps = eps
self.param_groups = self._normalize_param_groups(params, lr, weight_decay)
RecordingAdamW.created.append(self)
@staticmethod
def _normalize_param_groups(params, lr, weight_decay):
if isinstance(params, (list, tuple)) and params and isinstance(params[0], dict):
groups = []
for group in params:
normalized = dict(group)
normalized['params'] = list(group['params'])
normalized.setdefault('lr', lr)
groups.append(normalized)
return groups
return [{
'params': list(params),
'lr': lr,
'weight_decay': weight_decay,
}]
def state_dict(self):
return {}
def load_state_dict(self, state_dict):
return None
def zero_grad(self):
return None
def step(self):
return None
class RecordingTransformerHead(nn.Module):
def __init__(self):
super().__init__()
self.proj = nn.Linear(4, 4)
self.norm = nn.LayerNorm(4)
self.optim_group_calls = []
def get_optim_groups(self, weight_decay):
self.optim_group_calls.append(weight_decay)
return [
{
'params': [self.proj.weight],
'weight_decay': weight_decay,
},
{
'params': [self.proj.bias, self.norm.weight, self.norm.bias],
'weight_decay': 0.0,
},
]
class FakeIMFAgent(nn.Module):
def __init__(self):
super().__init__()
self.head_type = 'imf_transformer'
self.noise_pred_net = RecordingTransformerHead()
self.backbone = nn.Linear(4, 3)
self.adapter = nn.Linear(3, 2, bias=False)
class FakeTransformerAgent(nn.Module):
def __init__(self, *, head_type='transformer'):
super().__init__()
self.head_type = head_type
self.noise_pred_net = RecordingTransformerHead()
self.backbone = nn.Linear(4, 3)
self.adapter = nn.Linear(3, 2, bias=False)
self.frozen = nn.Linear(2, 2)
for param in self.frozen.parameters():
param.requires_grad = False
def to(self, device):
return self
def get_normalization_stats(self):
return {}
class TrainVLATransformerOptimizerTest(unittest.TestCase):
def setUp(self):
RecordingAdamW.created = []
def _load_train_vla_module(self):
hydra_module = types.ModuleType('hydra')
hydra_utils_module = types.ModuleType('hydra.utils')
hydra_utils_module.instantiate = lambda *args, **kwargs: None
def hydra_main(**_kwargs):
def decorator(func):
return func
return decorator
hydra_module.main = hydra_main
hydra_module.utils = hydra_utils_module
class OmegaConfStub:
_resolvers = {}
@classmethod
def has_resolver(cls, name):
return name in cls._resolvers
@classmethod
def register_new_resolver(cls, name, resolver):
cls._resolvers[name] = resolver
@staticmethod
def to_yaml(_cfg):
return 'stub-config'
omegaconf_module = types.ModuleType('omegaconf')
omegaconf_module.DictConfig = dict
omegaconf_module.OmegaConf = OmegaConfStub
module_name = 'train_vla_optimizer_test_module'
spec = importlib.util.spec_from_file_location(module_name, _TRAIN_VLA_PATH)
module = importlib.util.module_from_spec(spec)
with mock.patch.dict(
sys.modules,
{
'hydra': hydra_module,
'hydra.utils': hydra_utils_module,
'omegaconf': omegaconf_module,
},
):
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def _make_cfg(self):
return AttrDict(
train=AttrDict(
device='cpu',
batch_size=2,
num_workers=0,
val_split=0,
seed=0,
lr=1e-4,
max_steps=0,
log_freq=1,
save_freq=100,
warmup_steps=1,
scheduler_type='constant',
min_lr=0.0,
grad_clip=1.0,
weight_decay=0.123,
pretrained_ckpt=None,
resume_ckpt=None,
),
data=AttrDict(
camera_names=('front',),
),
agent=AttrDict(
_target_='fake.agent',
),
)
def _group_names(self, agent, optimizer):
names_by_param_id = {id(param): name for name, param in agent.named_parameters()}
return [
{names_by_param_id[id(param)] for param in group['params']}
for group in optimizer.param_groups
]
def test_clean_ld_preload_value_removes_problematic_nxegl_entry(self):
module = self._load_train_vla_module()
cleaned, changed = module._clean_ld_preload_value(
'/usr/lib/libfoo.so /usr/NX/lib/libnxegl.so /usr/lib/libbar.so'
)
self.assertTrue(changed)
self.assertEqual(cleaned, '/usr/lib/libfoo.so /usr/lib/libbar.so')
def test_clean_ld_preload_value_leaves_safe_entries_unchanged(self):
module = self._load_train_vla_module()
cleaned, changed = module._clean_ld_preload_value('/usr/lib/libfoo.so /usr/lib/libbar.so')
self.assertFalse(changed)
self.assertEqual(cleaned, '/usr/lib/libfoo.so /usr/lib/libbar.so')
def test_configure_cuda_runtime_can_disable_cudnn_for_training(self):
module = self._load_train_vla_module()
cfg = AttrDict(train=AttrDict(device='cuda', disable_cudnn=True))
original = module.torch.backends.cudnn.enabled
try:
module.torch.backends.cudnn.enabled = True
module._configure_cuda_runtime(cfg)
self.assertFalse(module.torch.backends.cudnn.enabled)
finally:
module.torch.backends.cudnn.enabled = original
def test_resolve_run_output_dir_prefers_hydra_runtime_output_dir(self):
module = self._load_train_vla_module()
hydra_core_module = types.ModuleType('hydra.core')
hydra_hydra_config_module = types.ModuleType('hydra.core.hydra_config')
class _Runtime:
output_dir = '/tmp/hydra-output'
class _Cfg:
runtime = _Runtime()
class HydraConfigStub:
@staticmethod
def initialized():
return True
@staticmethod
def get():
return _Cfg()
hydra_hydra_config_module.HydraConfig = HydraConfigStub
with mock.patch.dict(sys.modules, {
'hydra.core': hydra_core_module,
'hydra.core.hydra_config': hydra_hydra_config_module,
}):
output_dir = module._resolve_run_output_dir()
self.assertEqual(Path(output_dir).resolve(), Path('/tmp/hydra-output').resolve())
def test_train_script_uses_file_based_repo_root_on_sys_path(self):
module = self._load_train_vla_module()
fake_sys_path = ['/tmp/site-packages', '/another/path']
with mock.patch.object(module.sys, 'path', fake_sys_path):
repo_root = module._ensure_repo_root_on_syspath()
self.assertEqual(Path(repo_root).resolve(), _REPO_ROOT.resolve())
self.assertEqual(Path(fake_sys_path[0]).resolve(), _REPO_ROOT.resolve())
def test_non_transformer_head_with_get_optim_groups_still_uses_custom_groups(self):
module = self._load_train_vla_module()
agent = FakeIMFAgent()
optimizer = module.build_training_optimizer(agent, lr=1e-4, weight_decay=0.123)
self.assertEqual(agent.noise_pred_net.optim_group_calls, [0.123])
group_names = self._group_names(agent, optimizer)
self.assertEqual(group_names[0], {'noise_pred_net.proj.weight'})
self.assertEqual(group_names[1], {
'noise_pred_net.proj.bias',
'noise_pred_net.norm.weight',
'noise_pred_net.norm.bias',
})
self.assertEqual(group_names[2], {'backbone.weight', 'backbone.bias', 'adapter.weight'})
def test_transformer_training_prefers_head_optim_groups_and_keeps_remaining_trainable_params(self):
module = self._load_train_vla_module()
agent = FakeTransformerAgent()
cfg = self._make_cfg()
def fake_instantiate(config_node, **_kwargs):
if config_node is cfg.data:
return FakeDataset()
if config_node is cfg.agent:
return agent
raise AssertionError(f'unexpected instantiate config: {config_node!r}')
with tempfile.TemporaryDirectory() as tempdir:
previous_cwd = os.getcwd()
try:
os.chdir(tempdir)
with mock.patch.object(module, 'instantiate', side_effect=fake_instantiate), \
mock.patch.object(module, 'DataLoader', side_effect=lambda *args, **kwargs: FakeLoader()), \
mock.patch.object(module, 'get_lr_schedule_with_warmup', return_value=FakeScheduler()), \
mock.patch.object(module, 'AdamW', RecordingAdamW), \
mock.patch.object(module.torch, 'save', return_value=None), \
mock.patch.object(module, 'tqdm', side_effect=lambda iterable, **kwargs: FakeTqdm(iterable, **kwargs)):
module.main(cfg)
finally:
os.chdir(previous_cwd)
self.assertEqual(agent.noise_pred_net.optim_group_calls, [cfg.train.weight_decay])
optimizer = RecordingAdamW.created[-1]
trainable_names = {
name for name, param in agent.named_parameters() if param.requires_grad
}
grouped_names = self._group_names(agent, optimizer)
optimizer_names = set().union(*grouped_names)
expected_head_names = {
'noise_pred_net.proj.weight',
'noise_pred_net.proj.bias',
'noise_pred_net.norm.weight',
'noise_pred_net.norm.bias',
}
expected_non_head_names = {
'backbone.weight',
'backbone.bias',
'adapter.weight',
}
self.assertEqual(grouped_names[0], {'noise_pred_net.proj.weight'})
self.assertEqual(grouped_names[1], expected_head_names - {'noise_pred_net.proj.weight'})
self.assertEqual(grouped_names[2], expected_non_head_names)
self.assertEqual(optimizer.param_groups[0]['weight_decay'], cfg.train.weight_decay)
self.assertEqual(optimizer.param_groups[1]['weight_decay'], 0.0)
self.assertEqual(optimizer.param_groups[2]['weight_decay'], cfg.train.weight_decay)
self.assertEqual(optimizer_names, trainable_names)
flattened_param_ids = [
id(param)
for group in optimizer.param_groups
for param in group['params']
]
self.assertEqual(len(flattened_param_ids), len(set(flattened_param_ids)))
self.assertNotIn('frozen.weight', optimizer_names)
self.assertNotIn('frozen.bias', optimizer_names)
def test_any_head_with_get_optim_groups_uses_custom_groups_even_without_transformer_head_type(self):
module = self._load_train_vla_module()
agent = FakeTransformerAgent(head_type='imf')
with mock.patch.object(module, 'AdamW', RecordingAdamW):
optimizer = module.build_training_optimizer(agent, lr=1e-4, weight_decay=0.123)
self.assertEqual(agent.noise_pred_net.optim_group_calls, [0.123])
grouped_names = self._group_names(agent, optimizer)
self.assertEqual(grouped_names[0], {'noise_pred_net.proj.weight'})
self.assertEqual(
grouped_names[1],
{'noise_pred_net.proj.bias', 'noise_pred_net.norm.weight', 'noise_pred_net.norm.bias'},
)
self.assertEqual(grouped_names[2], {'backbone.weight', 'backbone.bias', 'adapter.weight'})
def test_transformer_optimizer_ignores_frozen_head_params_returned_by_head_groups(self):
module = self._load_train_vla_module()
agent = FakeTransformerAgent()
agent.noise_pred_net.norm.bias.requires_grad = False
cfg = self._make_cfg()
def fake_instantiate(config_node, **_kwargs):
if config_node is cfg.data:
return FakeDataset()
if config_node is cfg.agent:
return agent
raise AssertionError(f'unexpected instantiate config: {config_node!r}')
with tempfile.TemporaryDirectory() as tempdir:
previous_cwd = os.getcwd()
try:
os.chdir(tempdir)
with mock.patch.object(module, 'instantiate', side_effect=fake_instantiate), \
mock.patch.object(module, 'DataLoader', side_effect=lambda *args, **kwargs: FakeLoader()), \
mock.patch.object(module, 'get_lr_schedule_with_warmup', return_value=FakeScheduler()), \
mock.patch.object(module, 'AdamW', RecordingAdamW), \
mock.patch.object(module.torch, 'save', return_value=None), \
mock.patch.object(module, 'tqdm', side_effect=lambda iterable, **kwargs: FakeTqdm(iterable, **kwargs)):
module.main(cfg)
finally:
os.chdir(previous_cwd)
optimizer = RecordingAdamW.created[-1]
optimizer_names = set().union(*self._group_names(agent, optimizer))
trainable_names = {
name for name, param in agent.named_parameters() if param.requires_grad
}
self.assertEqual(agent.noise_pred_net.optim_group_calls, [cfg.train.weight_decay])
self.assertEqual(optimizer_names, trainable_names)
self.assertNotIn('noise_pred_net.norm.bias', optimizer_names)
if __name__ == '__main__':
unittest.main()
class TrainVLASmolVLAOptimizerTest(unittest.TestCase):
def test_build_training_optimizer_excludes_frozen_vlm_parameters_and_keeps_state_proj_and_head(self):
module = TrainVLATransformerOptimizerTest()._load_train_vla_module()
class _Head(nn.Module):
def __init__(self):
super().__init__()
self.proj = nn.Linear(2, 2)
def get_optim_groups(self, weight_decay):
return [{'params': list(self.parameters()), 'weight_decay': weight_decay}]
class _ConditionEncoder(nn.Module):
def __init__(self):
super().__init__()
self.vlm = nn.Linear(2, 2)
for param in self.vlm.parameters():
param.requires_grad = False
self.state_proj = nn.Linear(2, 2)
class _Agent(nn.Module):
def __init__(self):
super().__init__()
self.noise_pred_net = _Head()
self.condition_encoder = _ConditionEncoder()
agent = _Agent()
with mock.patch.object(module, 'AdamW', RecordingAdamW):
optimizer = module.build_training_optimizer(agent, lr=1e-4, weight_decay=0.01)
names_by_param_id = {id(param): name for name, param in agent.named_parameters()}
optimizer_names = {
names_by_param_id[id(param)]
for group in optimizer.param_groups
for param in group['params']
}
self.assertIn('condition_encoder.state_proj.weight', optimizer_names)
self.assertIn('condition_encoder.state_proj.bias', optimizer_names)
self.assertIn('noise_pred_net.proj.weight', optimizer_names)
self.assertNotIn('condition_encoder.vlm.weight', optimizer_names)
self.assertNotIn('condition_encoder.vlm.bias', optimizer_names)
def test_smolvla_native_training_preset_overrides_optimizer_scheduler_and_grad_clip(self):
module = TrainVLATransformerOptimizerTest()._load_train_vla_module()
class _NativeAgent(nn.Module):
def __init__(self):
super().__init__()
self.model = nn.Linear(2, 2)
def to(self, device):
return self
def get_normalization_stats(self):
return {}
agent = _NativeAgent()
cfg = TrainVLATransformerOptimizerTest()._make_cfg()
cfg.agent = AttrDict(_target_='roboimi.vla.agent_smolvla_native.SmolVLANativeAgent')
cfg.train.lr = 9e-4
cfg.train.max_steps = 1
cfg.train.weight_decay = 0.123
cfg.train.grad_clip = 1.0
cfg.train.warmup_steps = 7
cfg.train.scheduler_type = 'constant'
cfg.train.min_lr = 1e-7
scheduler_calls = []
clip_calls = []
def fake_instantiate(config_node, **_kwargs):
if config_node is cfg.data:
return FakeDataset()
if config_node is cfg.agent:
return agent
raise AssertionError(f'unexpected instantiate config: {config_node!r}')
def fake_scheduler(*args, **kwargs):
scheduler_calls.append(kwargs)
return FakeScheduler()
def fake_clip(parameters, max_norm):
clip_calls.append(float(max_norm))
return torch.tensor(0.0)
class OneBatchLoader:
def __len__(self):
return 1
def __iter__(self):
batch = {
'observation.front': torch.zeros(1, 1, 1, 2, 2),
'observation.state': torch.zeros(1, 1, 2),
'action': torch.zeros(1, 1, 2),
}
return iter([batch])
def fake_compute_loss(_batch):
return agent.model.weight.sum() * 0.0 + torch.tensor(1.0, requires_grad=True)
agent.compute_loss = fake_compute_loss
with tempfile.TemporaryDirectory() as tempdir:
previous_cwd = os.getcwd()
try:
os.chdir(tempdir)
with mock.patch.object(module, 'instantiate', side_effect=fake_instantiate), \
mock.patch.object(module, 'DataLoader', side_effect=lambda *args, **kwargs: OneBatchLoader()), \
mock.patch.object(module, 'get_lr_schedule_with_warmup', side_effect=fake_scheduler), \
mock.patch.object(module, 'AdamW', RecordingAdamW), \
mock.patch.object(module.torch.nn.utils, 'clip_grad_norm_', side_effect=fake_clip), \
mock.patch.object(module.torch, 'save', return_value=None), \
mock.patch.object(module, 'tqdm', side_effect=lambda iterable, **kwargs: FakeTqdm(iterable, **kwargs)):
module.main(cfg)
finally:
os.chdir(previous_cwd)
optimizer = RecordingAdamW.created[-1]
self.assertEqual(optimizer.lr, 1e-4)
self.assertEqual(optimizer.weight_decay, 1e-10)
self.assertEqual(optimizer.betas, (0.9, 0.95))
self.assertEqual(optimizer.eps, 1e-8)
self.assertEqual(scheduler_calls[-1], {
'warmup_steps': 1000,
'max_steps': 1,
'scheduler_type': 'cosine',
'min_lr': 2.5e-6,
})
self.assertEqual(clip_calls, [10.0])
def test_cosine_scheduler_spans_requested_training_steps_then_clamps(self):
module = TrainVLATransformerOptimizerTest()._load_train_vla_module()
param = nn.Parameter(torch.tensor(1.0))
optimizer = torch.optim.SGD([param], lr=1e-4)
base_lr = optimizer.param_groups[0]['lr']
scheduler = module.get_lr_schedule_with_warmup(
optimizer,
warmup_steps=1000,
max_steps=150000,
scheduler_type='cosine',
min_lr=2.5e-6,
)
lr_lambda = scheduler.lr_lambdas[0]
observed = {}
for step in (0, 1, 1000, 30000, 40000, 150000, 160000):
observed[step] = base_lr * lr_lambda(step)
self.assertGreater(observed[1], observed[0])
self.assertLess(observed[1000], 1e-4)
self.assertGreater(observed[30000], observed[40000])
self.assertGreater(observed[40000], observed[150000])
self.assertAlmostEqual(observed[150000], 2.5e-6, places=12)
self.assertAlmostEqual(observed[160000], 2.5e-6, places=12)