MoGe-3 / moge /train /utils.py
charlesjvt's picture
Deploy MoGe-3 Gradio demo
76c9728
Raw
History Blame Contribute Delete
20.7 kB
from typing import *
import fnmatch
import time
from pathlib import Path
from numbers import Number
from collections import Counter
import json
import numpy as np
import sympy
import torch
import torch.nn as nn
from ..utils.tools import flatten_nested_dict
def any_match(s: str, patterns: List[str]) -> bool:
return any(fnmatch.fnmatch(s, pat) for pat in patterns)
T = TypeVar('T')
def to_device(data: T, device: Union[str, torch.device]) -> T:
if isinstance(data, torch.Tensor):
return data.to(device)
elif isinstance(data, (list, tuple)):
return type(data)(to_device(item, device) for item in data)
elif isinstance(data, dict):
return {k: to_device(v, device) for k, v in data.items()}
else:
return data # For other types (e.g., int, float, str), return as is
def write_bytes_retry_loop(save_path: Path, data: bytes):
while True:
try:
save_path.write_bytes(data)
break
except Exception as e:
print('Error while saving checkpoint, retrying in 1 minute: ', e)
time.sleep(60)
def to_log_scalar(value: Any) -> Any:
if isinstance(value, torch.Tensor):
if value.numel() == 0:
return None
if value.numel() == 1:
return value.detach().reshape(())
return value.detach().float().mean()
if isinstance(value, np.ndarray):
if value.size == 0:
return None
return float(value.mean())
if isinstance(value, Number):
return float(value)
return None
def materialize_log_records(log_records: List[Dict[str, Any]]) -> List[Dict[str, float]]:
materialized: List[Dict[str, float]] = []
cuda_scalars: List[torch.Tensor] = []
cuda_slots: List[Tuple[Dict[str, float], str]] = []
for record in log_records:
out: Dict[str, float] = {}
materialized.append(out)
for key, value in record.items():
scalar = to_log_scalar(value)
if scalar is None:
continue
if isinstance(scalar, torch.Tensor):
scalar = scalar.detach()
if scalar.numel() != 1:
scalar = scalar.float().mean()
scalar = scalar.reshape(())
if scalar.is_cuda:
cuda_slots.append((out, key))
cuda_scalars.append(scalar.float())
else:
out[key] = float(scalar.float().item())
else:
out[key] = float(scalar)
if cuda_scalars:
values = torch.stack(cuda_scalars).cpu().tolist()
for (out, key), value in zip(cuda_slots, values):
out[key] = float(value)
return materialized
def group_loss_values(loss_value: torch.Tensor, group_size: int) -> torch.Tensor:
if loss_value.numel() == 1:
return loss_value.reshape(()).expand(group_size)
if loss_value.numel() == group_size:
return loss_value.reshape(group_size)
raise ValueError(f'Expected scalar or {group_size} loss values, got shape {tuple(loss_value.shape)}')
def select_group_log_value(value: Any, position: int, group_size: int) -> Any:
if isinstance(value, torch.Tensor):
if value.numel() == group_size:
return value.reshape(group_size)[position]
return to_log_scalar(value)
if isinstance(value, np.ndarray):
if value.size == group_size:
return value.reshape(group_size)[position]
return to_log_scalar(value)
return to_log_scalar(value)
def append_group_log_value(
group_records: List[Dict[str, Any]],
key: str,
value: Any,
positions: Optional[List[int]] = None,
):
if positions is None:
positions = list(range(len(group_records)))
for local_position, group_position in enumerate(positions):
scalar = select_group_log_value(value, local_position, len(positions))
if scalar is not None:
group_records[group_position][key] = scalar
def append_group_log_dict(
group_records: List[Dict[str, Any]],
prefix: str,
values: Dict[str, Any],
positions: Optional[List[int]] = None,
):
for key_tuple, value in flatten_nested_dict(values).items():
key = '.'.join(key_tuple)
append_group_log_value(group_records, f'{prefix}.{key}' if key else prefix, value, positions=positions)
def detach_to_cpu(x):
"""Recursively detach tensors and move them to CPU (for debug pickle dumps)."""
if isinstance(x, torch.Tensor):
return x.detach().cpu()
elif isinstance(x, (list, tuple)):
return type(x)(detach_to_cpu(item) for item in x)
elif isinstance(x, dict):
return {k: detach_to_cpu(v) for k, v in x.items()}
return x
def filter_outliers(values: List[float], sigma: float = 5.0) -> List[float]:
"""Filter outlier values using median + MAD (robust to outliers)."""
if len(values) < 10:
return values
sorted_vals = sorted(values)
n = len(sorted_vals)
median = sorted_vals[n // 2]
mad = sorted([abs(v - median) for v in values])[n // 2]
if mad < 1e-12:
return values
threshold = sigma * mad * 1.4826 # MAD to std conversion for normal distribution
return [v for v in values if abs(v - median) <= threshold]
ROLLING_CKPT_MANIFEST = 'rolling_ckpts.json'
def cleanup_old_rolling_ckpts(workspace: Path, current_step: int):
"""Keep only `current_step` among the rolling checkpoints this workspace has written.
Rolling checkpoints are tracked in `checkpoint/rolling_ckpts.json` rather than inferred
from the step number. Inferring them (e.g. "any step not divisible by checkpoint_every")
also matches checkpoints written by another run or another training script that used a
different cadence, and silently deletes them. Only steps this workspace recorded as
rolling are ever removed; anything else found in the directory is left alone.
The manifest is written before the deletions so a crash mid-cleanup leaves stale files
to be collected next time rather than losing track of them.
"""
ckpt_dir = Path(workspace, 'checkpoint')
if not ckpt_dir.exists():
return
manifest_path = Path(ckpt_dir, ROLLING_CKPT_MANIFEST)
try:
tracked = set(json.loads(manifest_path.read_text())) if manifest_path.exists() else set()
except (json.JSONDecodeError, OSError, TypeError):
tracked = set()
stale = sorted(step for step in tracked if step != current_step)
manifest_path.write_text(json.dumps(sorted(tracked - set(stale) | {current_step})))
for step in stale:
for suffix in ('', '_optimizer', '_ema'):
Path(ckpt_dir, f'{step:08d}{suffix}.pt').unlink(missing_ok=True)
def record_rolling_ckpt(workspace: Path, step: int):
"""Add `step` to the rolling-checkpoint manifest, so a later cleanup may remove it."""
ckpt_dir = Path(workspace, 'checkpoint')
ckpt_dir.mkdir(parents=True, exist_ok=True)
manifest_path = Path(ckpt_dir, ROLLING_CKPT_MANIFEST)
try:
tracked = set(json.loads(manifest_path.read_text())) if manifest_path.exists() else set()
except (json.JSONDecodeError, OSError, TypeError):
tracked = set()
manifest_path.write_text(json.dumps(sorted(tracked | {step})))
_OPTIMIZER_CONFIG_METADATA_KEYS = {'params', 'type', 'optimizer', 'optimizer_type', 'name'}
def _get_param_group_optimizer_type(optimizer_config: Dict[str, Any], param_group_config: Dict[str, Any]) -> str:
optimizer_type = param_group_config.get(
'type',
param_group_config.get(
'optimizer_type',
param_group_config.get('optimizer', optimizer_config.get('type', 'AdamW')),
),
)
if not isinstance(optimizer_type, str):
raise TypeError(f'Optimizer type must be a string, got {type(optimizer_type)}')
return optimizer_type
def _get_torch_optimizer_cls(optimizer_type: str) -> Type[torch.optim.Optimizer]:
if hasattr(torch.optim, optimizer_type):
return getattr(torch.optim, optimizer_type)
optimizer_type_lower = optimizer_type.lower()
for attr_name in dir(torch.optim):
if attr_name.lower() == optimizer_type_lower:
optimizer_cls = getattr(torch.optim, attr_name)
if isinstance(optimizer_cls, type) and issubclass(optimizer_cls, torch.optim.Optimizer):
return optimizer_cls
raise AttributeError(f'torch.optim has no optimizer named {optimizer_type}')
def _optimizer_option_items(config: Dict[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in config.items() if k not in _OPTIMIZER_CONFIG_METADATA_KEYS}
def _build_named_param_groups(
model: nn.Module,
optimizer_config: Dict[str, Any],
) -> Tuple[Dict[str, nn.Parameter], List[Dict[str, nn.Parameter]]]:
named_parameters = {k: p for k, p in model.named_parameters() if p.requires_grad}
named_param_groups: List[Dict[str, nn.Parameter]] = []
param_to_group: Dict[str, int] = {}
duplicated_params: List[str] = []
for group_idx, param_group_config in enumerate(optimizer_config['params']):
group_params = {
k: p
for k, p in named_parameters.items()
if any_match(k, param_group_config['params']['include'])
and not any_match(k, param_group_config['params'].get('exclude', []))
}
named_param_groups.append(group_params)
for name in group_params:
if name in param_to_group:
duplicated_params.append(name)
param_to_group[name] = group_idx
excluded_params = [k for k in named_parameters if k not in param_to_group]
assert len(duplicated_params) == 0, f'The following parameters are included in multiple optimizer groups: {duplicated_params}'
assert len(excluded_params) == 0, f'The following parameters require grad but are excluded from the optimizer: {excluded_params}'
return named_parameters, named_param_groups
def _normalize_torch_optimizer_options(options: Dict[str, Any]) -> Dict[str, Any]:
options = dict(options)
if 'wd' in options:
options.setdefault('weight_decay', options.pop('wd'))
if 'adamw_betas' in options:
options.setdefault('betas', options.pop('adamw_betas'))
if 'adamw_eps' in options:
options.setdefault('eps', options.pop('adamw_eps'))
return options
def _optimizer_group_metadata(param_group_config: Dict[str, Any], optimizer_type: str) -> Dict[str, Any]:
metadata = {'optimizer_type': optimizer_type}
if 'name' in param_group_config:
metadata['name'] = param_group_config['name']
return metadata
def build_optimizer(model: nn.Module, optimizer_config: Dict[str, Any]) -> torch.optim.Optimizer:
named_parameters, named_param_groups = _build_named_param_groups(model, optimizer_config)
param_group_optimizer_types = [
_get_param_group_optimizer_type(optimizer_config, param_group_config)
for param_group_config in optimizer_config['params']
]
unique_optimizer_types = {optimizer_type.lower() for optimizer_type in param_group_optimizer_types}
if len(unique_optimizer_types) != 1:
raise ValueError(f'Mixing multiple torch optimizer types in one optimizer is not supported: {param_group_optimizer_types}')
optimizer_cls = _get_torch_optimizer_cls(param_group_optimizer_types[0])
optimizer_defaults = _normalize_torch_optimizer_options(_optimizer_option_items(optimizer_config))
param_groups = [
{
**_normalize_torch_optimizer_options(_optimizer_option_items(param_group_config)),
**_optimizer_group_metadata(param_group_config, optimizer_type),
'params': list(params.values()),
}
for param_group_config, params, optimizer_type in zip(
optimizer_config['params'],
named_param_groups,
param_group_optimizer_types,
)
]
return optimizer_cls(param_groups, **optimizer_defaults)
def _is_dino_backbone_parameter(name: str) -> bool:
return name == 'backbone' or name.startswith('backbone.') or '.backbone.' in name
def _is_head_parameter(name: str) -> bool:
first_name = name.split('.', 1)[0]
return first_name in {'head', 'neck'} or first_name.endswith('_head')
def _is_refiner_parameter(name: str) -> bool:
return name == 'refiner' or name.startswith('refiner.') or '.refiner.' in name
def _optimizer_assignment_name(optimizer: torch.optim.Optimizer) -> str:
return optimizer.__class__.__name__.lower()
def write_optimizer_param_assignment_log(
model: torch.nn.Module,
optimizer: torch.optim.Optimizer,
workspace: Path,
) -> None:
group_by_param_id: Dict[int, Tuple[int, Dict[str, Any]]] = {}
for group_idx, group in enumerate(optimizer.param_groups):
group_options = {k: v for k, v in group.items() if k != 'params'}
for p in group['params']:
group_by_param_id[id(p)] = (group_idx, group_options)
records = []
summary = Counter()
numel_summary = Counter()
for name, parameter in model.named_parameters():
group_info = group_by_param_id.get(id(parameter))
if group_info is None:
assignment = 'not_optimized'
group_idx = None
group_options = {}
else:
group_idx, group_options = group_info
assignment = _optimizer_assignment_name(optimizer)
numel = parameter.numel()
summary[assignment] += 1
numel_summary[assignment] += numel
records.append({
'name': name,
'assignment': assignment,
'requires_grad': parameter.requires_grad,
'shape': list(parameter.shape),
'numel': numel,
'param_group': group_idx,
'optimizer_type': group_options.get('optimizer_type', optimizer.__class__.__name__),
'param_group_name': group_options.get('name'),
'lr': group_options.get('lr'),
'wd': group_options.get('wd', group_options.get('weight_decay')),
'is_dino_backbone': _is_dino_backbone_parameter(name),
'is_head': _is_head_parameter(name),
'is_refiner': _is_refiner_parameter(name),
})
log = {
'optimizer': optimizer.__class__.__name__,
'summary': {
assignment: {
'parameter_count': summary[assignment],
'numel': numel_summary[assignment],
}
for assignment in sorted(summary)
},
'parameters': records,
}
workspace.mkdir(parents=True, exist_ok=True)
json_path = workspace / 'optimizer_param_assignments.json'
tsv_path = workspace / 'optimizer_param_assignments.tsv'
with json_path.open('w') as f:
json.dump(log, f, indent=4)
with tsv_path.open('w') as f:
f.write('name\tassignment\trequires_grad\tshape\tnumel\tparam_group\toptimizer_type\tparam_group_name\tlr\twd\tis_dino_backbone\tis_head\tis_refiner\n')
for record in records:
f.write(
f"{record['name']}\t{record['assignment']}\t{record['requires_grad']}\t"
f"{record['shape']}\t{record['numel']}\t{record['param_group']}\t"
f"{record['optimizer_type']}\t{record['param_group_name']}\t"
f"{record['lr']}\t{record['wd']}\t{record['is_dino_backbone']}\t"
f"{record['is_head']}\t{record['is_refiner']}\n"
)
print(f'Optimizer parameter assignment log: {json_path}')
print(f'Optimizer parameter assignment table: {tsv_path}')
def parse_lr_lambda(s: str) -> Callable[[int], float]:
epoch = sympy.symbols('epoch')
lr_lambda = sympy.sympify(s)
return sympy.lambdify(epoch, lr_lambda, 'math')
def build_lr_scheduler(optimizer: torch.optim.Optimizer, scheduler_config: Dict[str, Any]) -> torch.optim.lr_scheduler._LRScheduler:
if scheduler_config['type'] == "SequentialLR":
child_schedulers = [
build_lr_scheduler(optimizer, child_scheduler_config)
for child_scheduler_config in scheduler_config['params']['schedulers']
]
return torch.optim.lr_scheduler.SequentialLR(optimizer, schedulers=child_schedulers, milestones=scheduler_config['params']['milestones'])
elif scheduler_config['type'] == "LambdaLR":
lr_lambda = scheduler_config['params']['lr_lambda']
if isinstance(lr_lambda, str):
lr_lambda = parse_lr_lambda(lr_lambda)
elif isinstance(lr_lambda, list):
lr_lambda = [parse_lr_lambda(l) for l in lr_lambda]
return torch.optim.lr_scheduler.LambdaLR(
optimizer,
lr_lambda=lr_lambda,
)
else:
scheduler_cls = getattr(torch.optim.lr_scheduler, scheduler_config['type'])
scheduler = scheduler_cls(optimizer, **scheduler_config.get('params', {}))
return scheduler
def refine_step_pairs(refine_steps: int) -> List[Tuple[int, int]]:
"""The refine-step transitions the monitor tables report, for a run of `refine_steps` steps.
Every consecutive transition, then the two spans 0->n (what refinement
achieved overall), and 1->n (the refiner's incremental contribution).
"""
pairs = [(i, i + 1) for i in range(refine_steps)]
for span in ((0, refine_steps), (1, refine_steps)):
if span[0] < span[1] and span not in pairs:
pairs.append(span)
return pairs
def split_step_suffix(key: str) -> Tuple[str, int]:
"""Split a logged key into its base name and refine step: 'global_step_2' -> ('global', 2).
A key with no suffix is step 0, which is how step-0 losses are logged.
"""
base, sep, step = key.rpartition('_step_')
return (base, int(step)) if sep else (key, 0)
def accumulate_step_transitions(
values_by_step: Dict[str, Dict[int, float]],
tracker: Dict[Tuple, List[int]],
count_when: Callable[[float, float], bool],
pairs: Sequence[Tuple[int, int]],
) -> None:
"""Tally, per (name, step_from, step_to), how many instances satisfy `count_when`.
`tracker` accumulates `[count, total]`. What the count *means* is decided by
`count_when(value_at_to, value_at_from)` and must match how the corresponding
table reports it -- the loss tracker counts instances that got *worse* and its
table inverts, while the delta and error trackers count the outcome they name.
"""
for name, step_vals in values_by_step.items():
for step_from, step_to in pairs:
if step_from in step_vals and step_to in step_vals:
entry = tracker.setdefault((name, step_from, step_to), [0, 0])
entry[1] += 1
if count_when(step_vals[step_to], step_vals[step_from]):
entry[0] += 1
def write_refine_monitor_table(
pbar,
i_step: int,
tracker: Dict[Tuple, Tuple[int, int]],
log: Dict[str, float],
title: str,
label: str,
log_prefix: str,
pairs: Sequence[Tuple[int, int]],
invert: bool = False,
) -> None:
"""Print one "% of instances that improved" table over refine-step transitions.
`tracker` maps (name, step_from, step_to) -> (count, total). The percentage
reported is `count / total`, or its complement when `invert` is set -- which
the loss table needs because it counts instances whose loss *increased* but
reports the fraction that decreased.
Consumes the tracker: it is cleared once written. Percentages are also
written into `log` under `log_prefix` for upload with the next metric batch.
"""
if not tracker:
return
pbar.write(f'[Step {i_step}] {title}')
names = sorted({name for name, _, _ in tracker})
name_width = max(len(label), *(len(name) for name in names))
header = ' '.join(f'{f"{a}->{b}":>7s}' for a, b in pairs)
pbar.write(f' {label:<{name_width}s} {header}')
for name in names:
cells = []
for step_from, step_to in pairs:
entry = tracker.get((name, step_from, step_to))
if entry is None:
cells.append(' -')
continue
count, total = entry
# NOTE: a zero-total cell prints as N/A but is still logged as 0.0,
# so the metric's key set stays stable across steps.
pct = 100.0 * ((total - count) if invert else count) / total if total > 0 else 0.0
cells.append(f'{pct:6.1f}%' if total > 0 else ' N/A')
log[f'{log_prefix}/{name}_{step_from}_to_{step_to}'] = pct
pbar.write(f' {name:<{name_width}s} {" ".join(cells)}')
tracker.clear()