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

GenAlgSolver (the package)

running the GA; keeping current state in memory

output.log, and evolution.png if plot_results=True — nothing else

Your fitness_function

scoring one candidate

intermediates only it can see, if you write them out

Your launcher.py

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?

solver.printable_fitness

current generation, raw per-objective

no — overwritten each calculate_fitness

solver.fitness_

current generation, scalarized

no

solver.population_

current generation

no

solver.mean_fitness_, solver.max_fitness_

whole run, scalarized

in memory until the process exits

solver.best_individual_, best_fitness_, best_pfitness_

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

  1. --outdir, created up front, logger_file pointed into it.

  2. set_output_dir(outdir) before solving, if the fitness writes anything.

  3. Fitness-side: append per-candidate intermediates at the point of computation; per-candidate scratch directories for external tools.

  4. Launcher-side: per-cycle printable_fitness + fitness_ + assembled candidate, one row each, flushed.

  5. End of run: mean_fitness_/max_fitness_, write_population(), close_solver_logger().

  6. Add a .gitignore for 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

examples/ga_flp

raw_data.txt (9 columns of chemistry per candidate), scratch/candidate_{n}/

gen{i}/ depictions, one directory per cycle

examples/ga_sf

examples/ga_niether

scratch candidate.xyz

examples/ga_bipy

candidate column holds gene names, not the assembled object

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.