Use Python
Python: Config Objects
Build a run out of typed objects, hand it to GraphEvolver, and read the results back
as Python values. Nothing to look up in another file: the whole run reads top to bottom.
Install
Version 0.9.0 is published on PyPI as platform wheels. Install the distribution into a virtual
environment; Python imports it as get:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install graph-evolution-tool
python -c "import get; print(get.__name__)"
py -m venv .venv,
skip the activate line entirely, because a stock machine refuses to run it, and
write .venv\Scripts\python.exe wherever a command on this page says
python or python3. pip becomes
.venv\Scripts\python.exe -m pip. That substitution is the whole Windows route;
nothing else on this page differs. If you would rather have an activated shell, see
PowerShell refuses to run
Activate.ps1.
graph-evolution-tool, while Python imports get. Pin
graph-evolution-tool==0.9.0 for an archived environment.
py.typed and a complete type
stub, so an editor or language server shows every signature, argument type and docstring for
Config, GraphEvolver and RunResult as you type, offline,
and always matching the version you installed. mypy checks your calls against it too.
The Smallest Complete Run
import get
config = get.Config(
population_size=60,
network_size=40,
crossover_rate=0.9,
mutation_rate=0.2,
evolution=get.EvolutionConfig.Generational(num_generations=50, elite_count=1),
scope=get.ScopeConfig.Global(),
selection=get.SelectionConfig.Tournament(tournament_size=5),
genome=get.GenomeConfig.EdgeEdit(gene_length=256),
fitness=get.FitnessConfig.EpiSpread(
sir=get.SirParams(infection_rate=0.5, num_epidemics=30)
),
)
evolver = get.GraphEvolver.from_config(config)
results = evolver.run(seed=7, n_runs=1)
best = results[0]
print(best.best_fitness, len(best.best_edges))
run always returns a list, one result per replicate, even for
n_runs=1. That is deliberate: code written against a single run does not have to be
rewritten when you ask for twenty.
Reading a Result
| Attribute | What it holds |
|---|---|
best_fitness | The winning score, in the direction you asked for. |
best_edges | (u, v, multiplicity) triples, u < v. Pairs with no edge are absent. |
num_nodes | Node count of the winning graph. |
history | One row per logged iteration: iteration, best_fitness, mean_fitness, std_dev, ci_95. |
config_toml | The exact configuration this run used, as text. Archive it beside the results. |
first = best.history[0].best_fitness
last = best.history[-1]
print(f"{first:.3f} -> {last.best_fitness:.3f} over {last.iteration} generations")
best.save_logs("run_log.csv")
best.save_results("best_individual.txt")
best.save_config(".") # writes config.toml
Write config_toml out with your results. On this route the parameters live
in a program someone will edit, so the file is the only record of what a given set of numbers
was actually produced by. Write one config.toml per experiment invocation rather
than one per replicate, since every replicate in that invocation came from the same document.
save_results writes only the network. save_config(directory) writes one
shared config.toml for the experiment.
Several Replicates
One master seed goes in; GET derives a seed per replicate from it. Asking for more replicates does not change the ones you already had, and a replicate is reproduced by re-running with the same master seed and reading the same index, not by feeding its derived seed back in.
import os
results = evolver.run(seed=7, n_runs=8)
for run_index, result in enumerate(results):
print(run_index, result.best_fitness)
os.makedirs(f"output/run_{run_index}", exist_ok=True)
result.save_logs(f"output/run_{run_index}/run_log.csv")
save_logs,
save_results, and save_config write into a folder that must already
exist. They raise OSError if it is missing, so make the folder
first. The get-run CLI is different: --out builds the whole tree for
you.
Replicates are spread across cores. Cap that with max_cores if you are sharing the
machine: evolver.run(seed=7, n_runs=8, max_cores=4). Peak memory scales with
min(max_cores, n_runs), so lowering it lowers memory too.
Your Own Objective
Score graphs with a Python function instead of a built-in. Select
FitnessConfig.Python(), then register the callable and say which direction wins:
def total_edges(batch):
"""One score per individual, in the same order."""
return [float(len(edges)) for (num_nodes, edges) in batch]
config = get.Config(
...,
fitness=get.FitnessConfig.Python(),
)
evolver = get.GraphEvolver.from_config(config)
evolver.set_fitness_function(total_edges, "maximize")
results = evolver.run(seed=7, n_runs=1)
Three things the contract requires:
- It takes the whole population at once, a list of
(num_nodes, edge_list)pairs, and returns one number per input, in the same order. A per-individual function is not just slower here; it is dramatically slower, because every call pays the interpreter round trip. - Direction is declared, never inferred.
"minimize"or"maximize". Nothing can work it out from your function. NaNstops the run. Infinity is allowed, but pick the right one: it is+infunder"minimize"and-infunder"maximize"that mean "invalid, never select this". The opposite infinity is the best possible score, so it wins every tournament it enters. Watch for a zero denominator and0.0/0.0: an empty graph puts both within reach.
Starting From a Graph You Already Have
evolver = get.GraphEvolver.from_config(config)
# Call ONE of these, not both. A second loader that disagrees about the
# numbering raises, and one run has one numbering.
# from memory: (u, v, multiplicity), in your own node numbering
evolver.set_base_graph(40, [(0, 1, 1), (1, 2, 1)], min_node_index=0)
# from a file: one edge per line, with a `# nodes = N` header
evolver.set_base_graph_from_file("base_graph.csv", min_node_index=1)
- A repeated edge, canonical, so
2,5and5,2are the same edge, takes the last occurrence's weight. Earlier rows for that pair are overwritten, not added to. - A zero-weight row is accepted and adds no edge. Weight 0 is the absence of an
edge, so the row is read, counted as valid, and leaves the pair unconnected. It is not an
edge with weight zero sitting in the graph, and it will not appear in
best_edges. The warning's phrase is "kept as given", which means the number you wrote was stored verbatim rather than treated as an error. - An empty file loads an empty base graph.
UserWarning naming the file and line; the in-memory
loader identifies the affected edge without a file location. In either case,
warnings.simplefilter("error") turns them into failures if you would rather find out
loudly.
A weight above max_edge_multiplicity is the opposite case and raises,
naming the file, the line and the cap rather than clamping silently.
The first loader you call fixes the run's node numbering, and results come back in it.
Load 1-indexed data and best_edges is 1-indexed too. A second loader that disagrees
is rejected rather than quietly mixed in: one run has one numbering.
A Complete Example
python_inline.py is this route end to end:
several replicates, an output directory per run, and all three files written per replicate. It
is not shipped in the wheel (the package carries the module and nothing else), so download it
and run python python_inline.py.
When to Use the Other Python Route
If you would rather keep the parameters in a document you can archive, diff and hand to someone else, write them as TOML instead. See Python: TOML File. The two routes accept exactly the same configurations; only where the parameters live differs.