8. Logging and saving run artifacts¶
A GA run produces more than a best individual: per-generation fitness curves, per-candidate scores, depictions, and whatever intermediate chemistry your fitness function computed on the way. The solver persists almost none of it. Deciding what to keep is your launcher’s job, and this page is the standard procedure for doing it.
The division of responsibility¶
Layer |
Owns |
Persists |
|---|---|---|
|
running the GA; keeping current state in memory |
|
Your |
scoring one candidate |
intermediates only it can see, if you write them out |
Your |
orchestration |
everything else: trajectories, curves, depictions, run directory layout |
The reason for the split is simple: the solver cannot know which of your intermediate quantities are worth keeping, and your fitness function cannot know which generation it is in.
What the solver holds, and for how long¶
Warning
solver.printable_fitness is overwritten every generation. If you want the raw per-objective history, it has to be written inside your cycle loop — nothing else keeps it.
Attribute |
Scope |
Survives the run? |
|---|---|---|
|
current generation, raw per-objective |
no — overwritten each |
|
current generation, scalarized |
no |
|
current generation |
no |
|
whole run, scalarized |
in memory until the process exits |
|
best so far |
in memory |
mean_fitness_/max_fitness_ accumulate, so one dump at the end is enough. Everything per-generation must be captured as it happens.
Step 1: give the run a directory¶
Take an --outdir argument and put everything under it, so runs don’t overwrite each other and a sweep is just a loop over output directories:
parser.add_argument("--outdir", default="output")
outdir = Path(args.outdir)
outdir.mkdir(parents=True, exist_ok=True)
solver, config = build_solver_from_yaml(
args.config, logger_file=str(outdir / "output.log")
)
Passing logger_file is what redirects the solver’s own log into the run directory; it defaults to output.log in the current directory otherwise.
Note
evolution.png (from plot_results=True) is the exception: its path is hardcoded in base_solver.py, so it always lands in the current working directory. Parallel runs in one directory overwrite each other’s copy.
Step 2: point your fitness module at that directory¶
If your fitness function writes anything, it needs to know where. The convention used by examples/ga_flp and examples/ga_niether is a module-level default that the launcher overrides once, before the GA starts:
# in your fitness module
_output_dir = Path(".")
def set_output_dir(path) -> None:
"""Call once from the launcher, before solving."""
global _output_dir
_output_dir = Path(path)
_output_dir.mkdir(parents=True, exist_ok=True)
# in launcher.py
from ga_core.my_fitness import set_output_dir
set_output_dir(outdir)
A module-level global is the pragmatic choice here because navicatGA.config resolves your fitness function by dotted reference and calls it with one argument — there is no hook for threading a path through.
Step 3: log what only the fitness function can see¶
This is the part people forget. A fitness function usually computes far more than it returns: examples/ga_flp derives a B–N distance, an angle, proton and hydride affinities, a synthetic-accessibility score and a frustration class, then returns just three scalars. The rest is gone unless it is written at the point of computation:
def overall_fitness_function(smiles):
dchem, bn_distance, angle, fepa, feha, scs, fr = expensive_analysis(smiles)
...
with open(_output_dir / "raw_data.txt", "a") as f:
print(f"{smiles},{dchem},{bn_distance},{angle},{fepa},{feha},{scs},{fr}", file=f)
return score3, score_geom, scs
Append rather than overwrite, since this is called once per candidate. The file ends up unordered with respect to generations — join it back to the trajectory on the SMILES if you need that.
If your fitness shells out to an external tool, give each candidate its own scratch directory. Many tools (xtb among them) write fixed, CWD-relative filenames and will otherwise clobber each other; see _xtb_scratch_dir in examples/ga_flp/ga_core/ga_flp.py. Keeping those directories also gives you a post-mortem dump for candidates that failed.
Step 4: log what only the launcher can see¶
Run one generation at a time and record between cycles:
import csv, numpy as np
with open(outdir / "trajectory.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["cycle", "generation", "smiles", "fitness", "objective_0", "objective_1"])
for cycle in range(config["num_cycles"]):
solver.solve(1)
raw = np.asarray(solver.printable_fitness)
if raw.ndim == 1: # single-objective: one value per candidate
raw = raw.reshape(-1, 1)
for chromosome, fitness, objectives in zip(
solver.population_, np.atleast_1d(solver.fitness_), raw
):
writer.writerow(
[cycle, solver.generations_, assemble(chromosome), fitness, *objectives]
)
f.flush() # so a long run can be watched live
np.savetxt(outdir / "mean_fitness.txt", solver.mean_fitness_)
np.savetxt(outdir / "max_fitness.txt", solver.max_fitness_)
solver.write_population(basename=str(outdir / "chromosome"))
solver.close_solver_logger()
One row per candidate per cycle is the format to prefer: it is the most granular thing available, and every summary — best per cycle, population mean, unique-candidate count — is a groupby away. Aggregating as you write throws away information you cannot get back.
write_population() depicts only the final population. Call it inside the loop with a per-cycle basename if you want depictions of every generation, as examples/ga_flp does with its gen{i}/ directories.
Step 5: report per cycle, if you want to watch¶
best = int(np.argmax(solver.fitness_))
print(f"[cycle {cycle}] fitness {solver.best_fitness_} "
f"| raw {solver.printable_fitness[best]} | {assemble(solver.population_[best])}")
The solver logs Best individual only at TRACE, so it is invisible at the default INFO level. Printing the raw objectives matters when a scalarizer is attached: a Chimera fitness frequently saturates at 1.0 within a few generations and then reports 1.0 forever, while the underlying objectives are still moving.
Checklist¶
--outdir, created up front,logger_filepointed into it.set_output_dir(outdir)before solving, if the fitness writes anything.Fitness-side: append per-candidate intermediates at the point of computation; per-candidate scratch directories for external tools.
Launcher-side: per-cycle
printable_fitness+fitness_+ assembled candidate, one row each, flushed.End of run:
mean_fitness_/max_fitness_,write_population(),close_solver_logger().Add a
.gitignorefor the run directory.
What the bundled examples do¶
Example |
Fitness-side |
Launcher-side |
|---|---|---|
All four bundled examples follow this procedure, so their launchers differ only where the problem genuinely differs: |
Example |
Fitness-side |
Launcher-side, beyond the standard set |
|---|---|---|
|
|
|
|
— |
— |
|
scratch |
— |
|
— |
|
The standard set every one of them writes is trajectory.csv, mean_fitness.txt, max_fitness.txt, output.log, and final chromosome_* depictions, all under --outdir.
ga_flp is the one to copy when your fitness computes expensive chemistry worth keeping; ga_sf when the objectives themselves are what you want to plot afterwards.
Plotting the result¶
examples/plot_evolution.py reads any trajectory.csv produced this way and writes one panel for the scalarized fitness plus one per objective, each showing the population mean against the cycle’s best-fitness candidate:
python ../plot_evolution.py output/ # -> output/evolution_plot.png
python ../plot_evolution.py runs/exp01 --out /tmp/exp01.png
It plots the selected candidate’s value per objective rather than a per-objective max(), because objectives are not all maximized - a minimized objective would otherwise be plotted upside down.
Naming the candidate column¶
candidate should be whatever identifies a chromosome most usefully and cheaply. For SMILES-based problems that is the assembled SMILES string, which costs a string concatenation. For 3D problems it is not: examples/ga_bipy’s assembler runs an AaronTools substitution and geometry minimization, so re-assembling every candidate purely to label a log row would dominate the run. It writes the gene names instead.
Warning
Do not write an AaronTools Geometry straight into a CSV: its __str__ is a whole xyz block, and the embedded newlines silently corrupt the file. Use its .name, which is the fragment file it came from.