mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
383 lines
21 KiB
Python
383 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""One finite CPU training job; Node owns scheduling, leases, and shutdown.
|
|
|
|
Usage: python train.py --job /path/to/job.json
|
|
Dependencies are packaged at image build time, never installed at runtime.
|
|
"""
|
|
import argparse
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import random
|
|
import resource
|
|
import signal
|
|
import time
|
|
import uuid
|
|
|
|
|
|
def emit(kind, **data):
|
|
print(json.dumps({'kind': kind, **data}, allow_nan=False), flush=True)
|
|
|
|
|
|
def atomic_torch_save(torch, value, path):
|
|
temporary = path.with_suffix('.tmp')
|
|
with temporary.open('wb') as stream:
|
|
torch.save(value, stream)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
temporary.replace(path)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--job')
|
|
parser.add_argument('--check-dependencies', action='store_true', help='Describe the installed production policy and exit without creating storage')
|
|
args = parser.parse_args()
|
|
# --help works even before Python dependencies are installed.
|
|
import numpy as np
|
|
import av
|
|
import torch
|
|
from torch.nn import functional as functional
|
|
from dataset import windows
|
|
from policy import Policy, VERSION, SLOTS, VALUES, specification, is_stop, select_slots, initial_schema
|
|
|
|
if args.check_dependencies:
|
|
emit('dependencies', torch=str(torch.__version__), numpy=str(np.__version__), av=str(av.__version__),
|
|
parameters=sum(p.numel() for p in Policy().parameters()), specification=specification(initial_schema()))
|
|
return
|
|
if not args.job:
|
|
parser.error('--job is required unless --check-dependencies is used')
|
|
|
|
job = json.loads(Path(args.job).read_text())
|
|
root = Path(job['root'])
|
|
config = job['config']
|
|
torch.set_num_threads(config['threads'])
|
|
torch.set_num_interop_threads(1)
|
|
# Training yields CPU priority to the Node/media processes. Thread count is
|
|
# an additional bound, not a claim to reserve specific physical cores.
|
|
os.nice(10)
|
|
stopped = False
|
|
|
|
def stop(_signal, _frame):
|
|
nonlocal stopped
|
|
stopped = True
|
|
|
|
signal.signal(signal.SIGTERM, stop)
|
|
signal.signal(signal.SIGINT, stop)
|
|
started = time.monotonic()
|
|
cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds']
|
|
model = Policy().cpu()
|
|
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
|
|
# The command-event policy has different inputs, targets and weights. Keep its
|
|
# optimizer lineage separate; reuse recordings with their unchanged partition.
|
|
resume_path = root / 'training' / 'resume-v5.pt'
|
|
schema, steps, examples, last_published = initial_schema(), 0, 0, 0
|
|
trained_rovers = set()
|
|
control_profiles = []
|
|
distinct_windows = {}
|
|
published_windows = 0
|
|
if resume_path.exists():
|
|
saved = torch.load(resume_path, map_location='cpu', weights_only=True)
|
|
if saved['version'] != VERSION:
|
|
raise ValueError('Resume schema incompatible; refusing to reinterpret existing weights')
|
|
model.load_state_dict(saved['weights'])
|
|
optimizer.load_state_dict(saved['optimizer'])
|
|
schema = saved['schema']
|
|
steps, examples = saved['steps'], saved['examples']
|
|
last_published = saved['lastPublished']
|
|
trained_rovers = set(saved['trainedRovers'])
|
|
control_profiles = saved.get('controlProfiles', [])
|
|
distinct_windows = saved.get('distinctWindows', {})
|
|
published_windows = saved.get('publishedWindows', 0)
|
|
cpu_started = time.process_time()
|
|
decode_started = time.monotonic()
|
|
dataset_stats = []
|
|
initial_steps = steps
|
|
pool, validation, accepted, rejected = [], [], [], {}
|
|
observed_windows = {}
|
|
sessions = list(job['sessions'])
|
|
random.shuffle(sessions)
|
|
is_held_out = lambda session_id: int(hashlib.sha256(session_id.encode()).hexdigest()[:8], 16) % 5 == 0
|
|
sessions.sort(key=is_held_out)
|
|
per_session = max(1, config['maxSamples'] // max(1, len(sessions)))
|
|
for session_id in sessions:
|
|
if cancelled():
|
|
break
|
|
candidate_schema = copy.deepcopy(schema)
|
|
samples, seen = [], 0
|
|
stats = {}
|
|
buckets = {key: [] for key in ('stop', 'command', 'none')}
|
|
counts = dict.fromkeys(buckets, 0)
|
|
held_out = is_held_out(session_id)
|
|
try:
|
|
directory = root / 'recordings' / session_id
|
|
for sample in windows(directory, candidate_schema, cancelled, stats, extend=not held_out):
|
|
seen += 1
|
|
# Keep evaluation naturally distributed. Training reserves space
|
|
# for recorded commands and releases so periods without commands
|
|
# cannot consume the entire sample budget. No scripted driving required.
|
|
if held_out:
|
|
reservoir, count, capacity = samples, seen, per_session
|
|
else:
|
|
category = ('stop' if any(sample[2][slot] and is_stop(descriptor)
|
|
for slot, descriptor in enumerate(candidate_schema)) else
|
|
'command' if sample[2].any() else 'none')
|
|
counts[category] += 1
|
|
reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 3)
|
|
if len(reservoir) < capacity:
|
|
reservoir.append(sample)
|
|
else:
|
|
index = random.randrange(count)
|
|
if index < capacity:
|
|
reservoir[index] = sample
|
|
if not held_out:
|
|
samples = [sample for bucket in buckets.values() for sample in bucket]
|
|
if cancelled():
|
|
break # Do not call a partially decoded recording validated.
|
|
if not samples:
|
|
if held_out and stats.get('unsupportedCommandEvents'):
|
|
dataset_stats.append({**stats, 'partition': 'heldOut', 'sampledWindows': 0,
|
|
'skipReason': 'No supported aligned windows'})
|
|
continue
|
|
raise ValueError('No complete, aligned observation/action windows')
|
|
# Entire sessions stay in one partition across all jobs. Neighboring
|
|
# video windows must never straddle training and validation.
|
|
held_out = is_held_out(session_id)
|
|
if held_out:
|
|
validation.extend(samples)
|
|
else:
|
|
schema = candidate_schema
|
|
pool.extend(samples)
|
|
observed_windows[session_id] = seen
|
|
accepted.append(session_id)
|
|
metadata = json.loads((directory / 'session.json').read_text())
|
|
if not held_out:
|
|
trained_rovers.add(str(metadata['roverId']))
|
|
profile = metadata.get('controlProfile')
|
|
if not held_out and profile is not None and profile not in control_profiles:
|
|
control_profiles.append(profile)
|
|
stats.update({'partition': 'heldOut' if held_out else 'training', 'sampledWindows': len(samples)})
|
|
dataset_stats.append(stats)
|
|
emit('dataset', sessionId=session_id, windows=seen, sampled=len(samples), diagnostics=stats)
|
|
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
|
|
rejected[session_id] = str(error)[:500]
|
|
emit('skipped', sessionId=session_id, reason=rejected[session_id])
|
|
if len(pool) < config['minimumSamples'] or not any(sample[2].any() for sample in pool) or cancelled():
|
|
emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool),
|
|
diagnostics={'dataset': dataset_stats, 'decodeSeconds': time.monotonic() - decode_started},
|
|
reason='Stopped/time limit' if cancelled() else 'Waiting for enough training-partition samples with human actions')
|
|
return
|
|
|
|
decode_seconds = time.monotonic() - decode_started
|
|
train_started = time.monotonic()
|
|
numeric_mask = torch.zeros(SLOTS, VALUES)
|
|
for slot, descriptor in enumerate(schema):
|
|
numeric_mask[slot, :len(descriptor['fields'])] = 1
|
|
# Balance sparse events without assuming that every time step should fire
|
|
# every control. This is training loss, not a driving-quality evaluation.
|
|
positives = np.sum([sample[2] for sample in pool], axis=0)
|
|
opportunities = np.sum([sample[4] for sample in pool], axis=0)
|
|
positive_weight = torch.from_numpy(np.clip((opportunities - positives) / np.maximum(positives, 1), 1, 20)).float()
|
|
losses = []
|
|
model.train()
|
|
for _ in range(config['passesPerJob']):
|
|
random.shuffle(pool)
|
|
for offset in range(0, len(pool), config['batchSize']):
|
|
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
|
break
|
|
batch = pool[offset:offset + config['batchSize']]
|
|
images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
|
images = images.float().div_(255)
|
|
optimizer.zero_grad(set_to_none=True)
|
|
action_logits, predictions = model(images, states)
|
|
event_loss = functional.binary_cross_entropy_with_logits(action_logits, targets,
|
|
pos_weight=positive_weight, reduction='none')
|
|
event_loss = (event_loss * available).sum() / available.sum().clamp_min(1)
|
|
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
|
value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
|
|
loss = event_loss + value_loss
|
|
if not torch.isfinite(loss):
|
|
raise ValueError('Nonfinite training loss; checkpoint not saved')
|
|
loss.backward()
|
|
torch.nn.utils.clip_grad_norm_(model.parameters(), 1)
|
|
optimizer.step()
|
|
steps += 1
|
|
examples += len(batch)
|
|
losses.append(loss.item())
|
|
if steps % 10 == 0:
|
|
emit('progress', steps=steps, examples=examples, loss=loss.item(),
|
|
losses={'events': event_loss.item(), 'commandValues': value_loss.item()})
|
|
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
|
break
|
|
if steps == initial_steps:
|
|
emit('complete', trained=False, accepted=[], rejected=rejected, reason='No training steps completed')
|
|
return
|
|
|
|
training_seconds = time.monotonic() - train_started
|
|
evaluation_started = time.monotonic()
|
|
distinct_windows.update(observed_windows)
|
|
unique_count = sum(distinct_windows.values())
|
|
coverage = [row for row in dataset_stats if row.get('partition') == 'heldOut']
|
|
unsupported_events = sum(row.get('unsupportedCommandEvents', 0) for row in coverage)
|
|
unsupported_windows = sum(row.get('unsupportedWindows', 0) for row in coverage)
|
|
evaluation = {'status': 'No supported held-out windows' if unsupported_events and not validation else 'waiting for held-out sessions',
|
|
'windows': len(validation), 'unsupportedCommandEvents': unsupported_events,
|
|
'unsupportedWindows': unsupported_windows}
|
|
if validation and not cancelled():
|
|
model.eval()
|
|
true_positive = predicted_positive = actual_positive = 0
|
|
value_error = value_count = evaluated = 0
|
|
stop_actual = stop_detected = false_stops = 0
|
|
drive_actual = drive_detected = 0
|
|
empty_windows = unwanted_windows = 0
|
|
stop_slots = [slot for slot, descriptor in enumerate(schema) if is_stop(descriptor)]
|
|
drive_slots = [slot for slot, descriptor in enumerate(schema)
|
|
if descriptor['template']['type'] == 'drive' and not is_stop(descriptor)]
|
|
per_command = [{'slot': slot, 'command': descriptor['template'], 'actual': 0,
|
|
'predicted': 0, 'matched': 0} for slot, descriptor in enumerate(schema)]
|
|
with torch.inference_mode():
|
|
for offset in range(0, len(validation), config['batchSize']):
|
|
if cancelled():
|
|
break
|
|
batch = validation[offset:offset + config['batchSize']]
|
|
images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
|
logits, predictions = model(images.float() / 255, states)
|
|
predicted = torch.zeros_like(targets, dtype=torch.bool)
|
|
# Use the same threshold and competing-command selection as live
|
|
# inference. Reservoir samples cannot assess temporal latching or
|
|
# closed-loop motion; report this explicitly rather than implying it.
|
|
for row, scores in enumerate(logits.sigmoid().tolist()):
|
|
for slot in select_slots(scores, schema, job['actionThreshold']):
|
|
predicted[row, slot] = True
|
|
actual = targets.bool()
|
|
true_positive += (predicted & actual).sum().item()
|
|
predicted_positive += predicted.sum().item()
|
|
actual_positive += actual.sum().item()
|
|
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
|
value_error += ((predictions - values).square() * mask).sum().item()
|
|
value_count += mask.sum().item()
|
|
empty = ~actual.any(dim=1)
|
|
empty_windows += empty.sum().item()
|
|
unwanted_windows += (empty & predicted.any(dim=1)).sum().item()
|
|
if stop_slots:
|
|
stop_actual += actual[:, stop_slots].sum().item()
|
|
stop_detected += (actual[:, stop_slots] & predicted[:, stop_slots]).sum().item()
|
|
false_stops += (predicted[:, stop_slots] & ~actual[:, stop_slots]).sum().item()
|
|
if drive_slots:
|
|
drive_actual += actual[:, drive_slots].sum().item()
|
|
drive_detected += (actual[:, drive_slots] & predicted[:, drive_slots]).sum().item()
|
|
for item in per_command:
|
|
slot = item['slot']
|
|
item['actual'] += actual[:, slot].sum().item()
|
|
item['predicted'] += predicted[:, slot].sum().item()
|
|
item['matched'] += (actual[:, slot] & predicted[:, slot]).sum().item()
|
|
evaluated += len(batch)
|
|
precision = true_positive / max(1, predicted_positive)
|
|
recall = true_positive / max(1, actual_positive)
|
|
mse = value_error / max(1, value_count)
|
|
stop_recall = stop_detected / max(1, stop_actual)
|
|
held_out_sessions = sum(row.get('partition') == 'heldOut' and not row.get('skipReason') for row in dataset_stats)
|
|
gate_reasons = []
|
|
for condition, reason in [
|
|
(unsupported_events == 0, 'Held-out controls are not fully represented; see unsupportedCommands'),
|
|
(evaluated == len(validation), 'Evaluation interrupted'),
|
|
(held_out_sessions >= 2, 'Need two usable held-out recordings'),
|
|
(evaluated >= 128, 'Need 128 held-out windows'),
|
|
(actual_positive >= 32, 'Need 32 held-out command events'),
|
|
(stop_actual >= 8, 'Need eight held-out stop events'),
|
|
(drive_actual >= 16, 'Need 16 held-out movement commands'),
|
|
(drive_detected / max(1, drive_actual) >= .5, 'Movement command recall below 0.5'),
|
|
(precision >= .5, 'Command precision below 0.5'),
|
|
(recall >= .5, 'Command recall below 0.5'),
|
|
(stop_recall >= .5, 'Stop recall below 0.5'),
|
|
(value_count == 0 or mse <= .1, 'Command parameter error above 0.1'),
|
|
]:
|
|
if not condition:
|
|
gate_reasons.append(reason)
|
|
evaluation = {'status': 'imitation gate not met' if gate_reasons else 'passed imitation gate',
|
|
'unsupportedCommandEvents': unsupported_events, 'unsupportedWindows': unsupported_windows,
|
|
'method': 'Held-out 100ms command events; not a closed-loop rollout',
|
|
'threshold': job['actionThreshold'], 'heldOutSessions': held_out_sessions,
|
|
'windows': evaluated, 'precision': precision, 'recall': recall,
|
|
'numericMse': mse, 'positiveActions': actual_positive,
|
|
'predictedActions': predicted_positive, 'falsePositiveActions': predicted_positive - true_positive,
|
|
'stopEvents': stop_actual, 'missedStops': stop_actual - stop_detected,
|
|
'stopRecall': stop_recall, 'falseStops': false_stops,
|
|
'movementEvents': drive_actual, 'movementRecall': drive_detected / max(1, drive_actual),
|
|
'noCommandWindows': empty_windows, 'unwantedCommandWindows': unwanted_windows,
|
|
'commands': per_command, 'gateReasons': gate_reasons}
|
|
evaluation_seconds = time.monotonic() - evaluation_started
|
|
publication = {'distinctMinutes': unique_count / 600,
|
|
'newMinutes': (unique_count - published_windows) / 600,
|
|
'minimumMinutes': config['minimumDrivingMinutes'],
|
|
'requiredNewMinutes': config['newDrivingMinutesPerModel']}
|
|
reasons = []
|
|
if publication['distinctMinutes'] < config['minimumDrivingMinutes']:
|
|
reasons.append('Collecting distinct driving footage')
|
|
if publication['newMinutes'] < config['newDrivingMinutesPerModel']:
|
|
reasons.append('Waiting for new driving footage since last publication')
|
|
if steps - last_published < config['checkpointEverySteps']:
|
|
reasons.append('Training steps below publication interval')
|
|
if evaluation['status'] != 'passed imitation gate':
|
|
reasons.append(evaluation['status'])
|
|
if cancelled():
|
|
reasons.append('Job time budget reached')
|
|
publication['reason'] = '; '.join(reasons) or 'Latest updated'
|
|
save_started = time.monotonic()
|
|
published = None
|
|
# Every completed job exports an immutable candidate; Node owns promotion and retention.
|
|
adjectives = ['amber', 'curious', 'gentle', 'quiet', 'silver', 'bright', 'merry', 'sleepy']
|
|
animals = ['otter', 'finch', 'badger', 'robin', 'fox', 'heron', 'marten', 'wren']
|
|
model_id = str(uuid.uuid4())
|
|
name = f'{random.choice(adjectives)}-{random.choice(animals)}-{model_id[:8]}'
|
|
checkpoint_root = root / 'training' / 'checkpoints'
|
|
checkpoint_root.mkdir(exist_ok=True)
|
|
staging = checkpoint_root / f'.{model_id}.pending'
|
|
staging.mkdir()
|
|
weights_path = staging / 'weights.pt'
|
|
atomic_torch_save(torch, model.state_dict(), weights_path)
|
|
published = {
|
|
'id': model_id, 'name': name, 'createdAt': int(time.time() * 1000),
|
|
'trainingSteps': steps, 'examplesProcessed': examples,
|
|
'parameters': sum(p.numel() for p in model.parameters()),
|
|
'weightBytes': weights_path.stat().st_size,
|
|
'sha256': hashlib.sha256(weights_path.read_bytes()).hexdigest(),
|
|
'specification': specification(schema), 'trainedRovers': sorted(trained_rovers),
|
|
'controlProfiles': control_profiles,
|
|
'sourceSessions': accepted, 'trainingLoss': sum(losses) / len(losses),
|
|
'evaluation': evaluation['status'], 'validation': evaluation, 'distinctMinutes': publication['distinctMinutes'], 'dtype': 'float32', 'torchVersion': str(torch.__version__),
|
|
}
|
|
(staging / 'model.json').write_text(json.dumps(published, indent=2, allow_nan=False))
|
|
# A catalog reader sees both weights and metadata, or neither. Existing
|
|
# versions are never overwritten or deleted by the trainer.
|
|
staging.rename(checkpoint_root / model_id)
|
|
if not reasons:
|
|
last_published = steps
|
|
published_windows = unique_count
|
|
atomic_torch_save(torch, {
|
|
'version': VERSION, 'weights': model.state_dict(), 'optimizer': optimizer.state_dict(),
|
|
'schema': schema, 'steps': steps, 'examples': examples,
|
|
'lastPublished': last_published, 'trainedRovers': sorted(trained_rovers),
|
|
'controlProfiles': control_profiles, 'distinctWindows': distinct_windows,
|
|
'publishedWindows': published_windows,
|
|
}, resume_path)
|
|
diagnostics = {'dataset': dataset_stats, 'decodeSeconds': decode_seconds,
|
|
'trainingSeconds': training_seconds, 'evaluationSeconds': evaluation_seconds, 'checkpointSeconds': time.monotonic() - save_started,
|
|
'cpuSeconds': time.process_time() - cpu_started,
|
|
'peakRssMiB': resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
|
|
'dependencies': {'torch': str(torch.__version__), 'numpy': str(np.__version__), 'av': str(av.__version__)},
|
|
'policy': specification(schema), 'browserVideoLatencyMs': None}
|
|
emit('complete', trained=True, accepted=accepted, rejected=rejected,
|
|
steps=steps, examples=examples, loss=sum(losses) / len(losses),
|
|
published=published, promote=not reasons, publication=publication, evaluation=evaluation, diagnostics=diagnostics, elapsedSeconds=time.monotonic() - started)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
main()
|
|
except Exception as error:
|
|
emit('error', message=str(error))
|
|
raise SystemExit(1)
|