8000 Fix training CLI checkpoint scheduling using actual action steps by jadenfix · Pull Request #6 · jadenfix/DRL4MM · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,30 @@ The default now requests historical input rather than silently running an
empty synthetic book. Install `requirements.txt` for TD3 training; optional
Weights & Biases is imported only when explicitly enabled.

### Training budgets and checkpoints

`training.total_timesteps` counts actual training actions. Short historical CSVs
are replayed as separate episodes until that budget is reached; the final
episode is capped to the remaining budget. This budget cutoff retains the
environment's terminal flag rather than treating truncation as a terminal TD3
transition.

`training.eval_frequency` and `agent.save_frequency` are positive integer
action-step intervals. `training.save_frequency`, when supplied, overrides the
legacy agent setting. Evaluation and periodic saving occur at episode
boundaries once the corresponding step threshold is crossed. One boundary
produces one checkpoint, even if its episode crossed several intervals.
Periodic files use `checkpoint_steps_<actual steps>.pth`; the final model is
always saved. Budgets smaller than one episode still train and save that final
model. Zero, negative, fractional, and boolean schedule values are rejected.

`--resume` restores online networks, optimizers, and agent iteration counters;
the requested step budget applies to the new invocation. Replay data, random
states, and lagged target networks are not restored, so this is not exact
continued-training recovery. Evaluation currently replays the configured CSV,
not a separate held-out dataset. Software fixture checks do not establish
market performance.

## Verify

```sh
Expand Down
5 changes: 3 additions & 2 deletions config/td3_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ agent:

# Training Configuration
training:
total_timesteps: 2000000
eval_frequency: 50000
total_timesteps: 2000000 # Actual training action steps, including repeated CSV episodes.
eval_frequency: 50000 # Action steps; evaluate at the next episode boundary.
# save_frequency: 10000 # Optional override of agent.save_frequency (also action steps).
eval_episodes: 10
log_frequency: 1000

Expand Down
135 changes: 135 additions & 0 deletions tests/test_training_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Public CLI checks using tiny software events, not historical market evidence."""
import csv
import logging
import os
from pathlib import Path
import subprocess
import sys

import numpy as np
import pytest
import torch
import yaml

from train.train_td3 import training_schedule, create_agent, train_episode
from train.evaluate import create_environment, load_agent
from utils.metrics import MetricsTracker

ROOT = Path(__file__).resolve().parents[1]


def write_config(tmp_path, *, total=8, horizon=23400, save=2, override=False):
"""Use the existing chronological test scenario and real small TD3 networks."""
source = tmp_path / 'software-events.csv'
with source.open('w', newline='') as stream:
writer = csv.writer(stream)
writer.writerow(['timestamp', 'event', 'order_id', 'side', 'price', 'quantity'])
writer.writerows([
[1700000000, 'add', 'bid', 'buy', 99, 20],
[1700000000, 'add', 'ask', 'sell', 101, 20],
[1700000001, 'market', 'sell_flow', 'sell', '', 1],
[1700000002, 'market', 'buy_flow', 'buy', '', 1],
[1700000003, 'add', 'next_bid', 'buy', 98, 20],
])
config = yaml.safe_load((ROOT / 'config/td3_config.yaml').read_text())
config['data']['historical_data_path'] = str(source)
config['environment'].update(max_episode_steps=horizon, quote_size=1)
config['simulation'].update(latency_mean=0, latency_std=0)
config['agent'].update(hidden_dims=[8, 8], buffer_size=32, batch_size=2,
save_frequency=10000 if override else save)
config['training'].update(total_timesteps=total, eval_frequency=1, use_wandb=False,
checkpoint_dir=str(tmp_path / 'checkpoints'),
model_save_dir=str(tmp_path / 'models'))
if override:
config['training']['save_frequency'] = save
config['logging']['log_dir'] = str(tmp_path / 'logs')
path = tmp_path / 'config.yaml'
path.write_text(yaml.safe_dump(config))
return path, config


def run_cli(module, arguments):
"""Execute the real public command without network or experiment tracking."""
return subprocess.run([sys.executable, '-m', module, *arguments], cwd=ROOT,
env={**os.environ, 'MPLBACKEND': 'Agg',
'OMP_NUM_THREADS': '1', 'MKL_NUM_THREADS': '1'},
capture_output=True, text=True, timeout=60)


@pytest.mark.parametrize('total,horizon,save,expected,override', [
(8, 23400, 2, [3, 6, 8], False),
(2, 23400, 1, [2], True),
(7, 2, 3, [4, 6], False),
(3, 23400, 10000, [], False),
])
def test_public_training_counts_actual_steps_and_saves_loadable_models(
tmp_path, total, horizon, save, expected, override):
"""Check short data, partial final episodes, interval crossings, and fallback."""
path, config = write_config(tmp_path, total=total, horizon=horizon,
save=save, override=override)
process = run_cli('train.train_td3', ['--config', str(path)])
assert process.returncode == 0, process.stdout + process.stderr
assert f'Training completed after {total} action steps' in process.stderr
assert sorted(p.name for p in (tmp_path / 'checkpoints').glob('checkpoint_steps_*.pth')) == [
f'checkpoint_steps_{step}.pth' for step in expected
]
final = tmp_path / 'models' / 'final_model.pth'
checkpoint = torch.load(final, map_location='cpu', weights_only=True)
assert checkpoint['total_iterations'] == total - 1
for key in ('actor_state_dict', 'critic_1_state_dict', 'critic_2_state_dict'):
assert all(torch.isfinite(value).all() for value in checkpoint[key].values())
for checkpoint_path in (tmp_path / 'checkpoints').glob('checkpoint_steps_*.pth'):
saved = torch.load(checkpoint_path, map_location='cpu', weights_only=True)
steps = int(checkpoint_path.stem.split('_')[-1])
assert saved['total_iterations'] == steps - 1
env = create_environment(config)
restored = load_agent(env, config, str(final))
assert np.isfinite(restored.select_action(env.reset(), add_noise=False)).all()


def test_public_evaluation_loads_training_checkpoint(tmp_path):
"""Verify train/checkpoint/load/evaluation through both public module CLIs."""
path, _ = write_config(tmp_path, total=4, save=2)
trained = run_cli('train.train_td3', ['--config', str(path)])
assert trained.returncode == 0, trained.stdout + trained.stderr
evaluated = run_cli('train.evaluate', [
'--config', str(path), '--model', str(tmp_path / 'models' / 'final_model.pth'),
'--episodes', '1', '--save_dir', str(tmp_path / 'evaluation'),
])
assert evaluated.returncode == 0, evaluated.stdout + evaluated.stderr
assert 'Loaded trained agent' in evaluated.stderr
assert (tmp_path / 'evaluation' / 'evaluation_stats.txt').exists()


@pytest.mark.parametrize('limit,terminal', [(2, False), (3, True)])
def test_budget_truncation_preserves_environment_terminal_flag(tmp_path, limit, terminal):
"""A partial budget bootstraps while genuine CSV exhaustion ends a transition."""
_, config = write_config(tmp_path)
env = create_environment(config)
agent = create_agent(env, config)
result = train_episode(env, agent, MetricsTracker(), 0,
logging.getLogger('budget-test'), step_limit=limit)
assert result['episode_steps'] == limit
assert result['budget_truncated'] is not terminal
assert bool(agent.replay_buffer.dones[limit - 1]) is terminal


@pytest.mark.parametrize('field', ['total_timesteps', 'eval_frequency', 'save_frequency', 'max_episode_steps'])
@pytest.mark.parametrize('value', [0, -1, 1.5, True])
def test_schedule_rejects_nonpositive_or_noninteger_steps(tmp_path, field, value):
"""Reject invalid intervals rather than rounding them into misleading schedules."""
_, config = write_config(tmp_path)
section = 'environment' if field == 'max_episode_steps' else 'training'
config[section][field] = value
with pytest.raises(ValueError, match=field + ' must be a positive integer'):
training_schedule(config)


def test_invalid_schedule_fails_before_creating_training_outputs(tmp_path):
"""The public CLI rejects zero intervals before models, logs, or tracking."""
path, config = write_config(tmp_path, save=0)
process = run_cli('train.train_td3', ['--config', str(path)])
assert process.returncode == 2
assert 'save_frequency must be a positive integer' in process.stderr
assert not Path(config['logging']['log_dir']).exists()
assert not Path(config['training']['model_save_dir']).exists()
65 changes: 50 additions & 15 deletions train/train_td3.py
< B33C /tr>
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,12 @@ def create_agent(env: MarketMakingEnv, config: Dict) -> TD3Agent:


def train_episode(env: MarketMakingEnv, agent: TD3Agent, metrics: MetricsTracker,
episode: int, logger: logging.Logger) -> Dict:
"""Train for one episode"""
episode: int, logger: logging.Logger, step_limit: int = None) -> Dict:
"""Train one episode, optionally capped by the remaining invocation budget.

A budget cutoff is a truncation, not an environment terminal transition:
retain the environment's done flag in replay for TD3 bootstrapping.
"""

# Reset environment
state = env.reset()
Expand All @@ -126,7 +130,7 @@ def train_episode(env: MarketMakingEnv, agent: TD3Agent, metrics: MetricsTracker

done = False

while not done:
while not done and (step_limit is None or episode_steps < step_limit):
# Select action
action = agent.select_action(state, add_noise=True)

Expand Down Expand Up @@ -169,7 +173,8 @@ def train_episode(env: MarketMakingEnv, agent: TD3Agent, metrics: MetricsTracker
'episode_trades': episode_trades,
'episode_pnl': episode_pnl,
'final_inventory': info.get('inventory', 0),
'final_position_value': info.get('position_value', 0)
'final_position_value': info.get('position_value', 0),
'budget_truncated': not done,
}


Expand Down Expand Up @@ -212,6 +217,25 @@ def evaluate_agent(env: MarketMakingEnv, agent: TD3Agent, num_episodes: int = 5)
}


def training_schedule(config: Dict) -> Dict:
"""Validate positive action-step budgets and intervals before side effects.

training.save_frequency overrides the legacy agent.save_frequency setting.
Frequencies count actual training transitions, not presumed episode length.
"""
training = config['training']
schedule = {
'total_timesteps 8BD0 ': training['total_timesteps'],
'eval_frequency': training['eval_frequency'],
'save_frequency': training.get('save_frequency', config['agent'].get('save_frequency', 10000)),
'max_episode_steps': config['environment']['max_episode_steps'],
}
for name, value in schedule.items():
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError(f'{name} must be a positive integer number of action steps')
return schedule


def main():
"""Main training function"""

Expand All @@ -229,6 +253,10 @@ def main():
# Load configuration
with open(args.config, 'r') as f:
config = yaml.safe_load(f)
try:
schedule = training_schedule(config)
except ValueError as error:
parser.error(str(error))

# Setup logging
logger = setup_logging(config)
Expand Down Expand Up @@ -262,18 +290,20 @@ def main():
return

# Training parameters
total_episodes = config['training']['total_timesteps'] // config['environment']['max_episode_steps']
eval_frequency = config['training']['eval_frequency'] // config['environment']['max_episode_steps']
save_frequency = config['training'].get('save_frequency', 10000) // config['environment']['max_episode_steps']

logger.info(f"Starting training for {total_episodes} episodes")
total_steps = 0
episode = 0
next_eval = schedule['eval_frequency']
next_save = schedule['save_frequency']
logger.info(f"Starting training for {schedule['total_timesteps']} action steps")

# Training loop
best_reward = -float('inf')

for episode in range(total_episodes):
while total_steps < schedule['total_timesteps']:
# Train episode
episode_metrics = train_episode(env, agent, metrics, episode, logger)
episode_metrics = train_episode(env, agent, metrics, episode, logger,
step_limit=schedule['total_timesteps'] - total_steps)
total_steps += episode_metrics['episode_steps']

# Log episode metrics
metrics.log_episode_metrics(episode_metrics, episode)
Expand All @@ -290,7 +320,10 @@ def main():
})

# Evaluation
if episode % eval_frequency == 0 and episode > 0:
# Run once at an episode boundary that crosses a step threshold. Avoid
# duplicate evaluations/checkpoints when a long episode spans intervals.
if total_steps >= next_eval:
next_eval = (total_steps // schedule['eval_frequency'] + 1) * schedule['eval_frequency']
eval_metrics = evaluate_agent(env, agent)
logger.info(f"Episode {episode} evaluation: {eval_metrics}")

Expand All @@ -314,16 +347,18 @@ def main():
logger.info(f"Saved best model to {best_model_path}")

# Periodic saving
if episode % save_frequency == 0 and episode > 0:
if total_steps >= next_save:
next_save = (total_steps // schedule['save_frequency'] + 1) * schedule['save_frequency']
checkpoint_dir = config['training']['checkpoint_dir']
os.makedirs(checkpoint_dir, exist_ok=True)

checkpoint_path = os.path.join(checkpoint_dir, f'checkpoint_episode_{episode}.pth')
checkpoint_path = os.path.join(checkpoint_dir, f'checkpoint_steps_{total_steps}.pth')
agent.save(checkpoint_path)
logger.info(f"Saved checkpoint to {checkpoint_path}")
episode += 1

# Final evaluation
logger.info("Training completed. Running final evaluation...")
logger.info(f"Training completed after {total_steps} action steps across {episode} episodes. Running final evaluation...")
final_eval_metrics = evaluate_agent(env, agent, num_episodes=20)
logger.info(f"Final evaluation results: {final_eval_metrics}")

Expand Down
Loading
0