Blog · Research infrastructure ·
The config file is the experiment
A declarative parser pattern from a 2019 3D-vision benchmark harness: fail loudly at parse time, quietly at run time, and let the output path double as the checkpoint.
- Interactive
- python
- config
- tooling
- research-infrastructure
- yaml
By November 2019 my masters evaluation platform had grown to four algorithms
(3DMV, MinkowskiEngine at two voxel sizes, SemanticFusion), two datasets (ScanNet,
NYUv2), four kinds of assessment, and enough hyperparameters per algorithm that a
single SemanticFusion run needed 25 of them. Some runs took hours on a GPU I was
sharing with three other people. Argparse does not scale to that, and re-typing forty
flags by hand every time I wanted to swap one voxel size is how you lose an evening to
a typo you don’t notice until the run is half finished. The fix, one YAML file per
experiment, is the whole subject of this post, and the repo’s own README.md says why
better than I can retell it:
Three sentences, and they commit to three separate engineering decisions: the config
is the experiment record, parsing happens in one pass before anything runs, and
runtime failures are contained to the thing that failed. The rest of this post is how
segtester/configs/base.py actually implements the first two, and how the platform’s
call sites implement the third.
The workflow
assets/eval_platform_design.pdf is a five-box draw.io flowchart. Redrawn:
Redrawn from assets/eval_platform_design.pdf (final version, dated 2019-11-17, the
same day as this post). An earlier draft, eval_platform_design_m.pdf, has the same
five boxes with a typo, “relivant”, in the third.
Everything left of that last accent-coloured box is RunnableConfig.parse_from_yaml_file
running once, all the way through, before a single algorithm executes. Everything inside
it is what “skip any scenes that cause errors” turns into in real code, which I’ll get to
after the parser.
The parser primitives
segtester/configs/base.py builds one small vocabulary of “members” that every config
class is written in terms of. A config class is a plain Python object whose __init__
assigns each field to one of these, instead of reading it straight from a dict:
# segtester/configs/runnablebase.py:7-15
class RunnableConfig(BCNF.ConfigParser):
def __init__(self):
super().__init__()
self.name: str = BCNF.OptionalMember()
self.description: str = BCNF.OptionalMember()
self.base_result_path: str = BCNF.RequiredMember()
self.tests: List[Callable] = BCNF.RequiredMember(BCNF.IterableMember(BCNF.MappableMember(RUNNABLE_MAP)))
That’s the entire “Base Config” box from the schema diagram, and it already shows the
two axes the members vary on: required or optional, and plain scalar or nested
parser. OptionalMember and RequiredMember are the same class with one difference:
RequiredMember just checks the key exists before delegating to its parent’s logic:
# segtester/configs/base.py:5-27
class OptionalMember:
def __init__(self, parsable_object=None, default_ret=None):
parse_op = getattr(parsable_object, "_parse_args", None)
assert (parsable_object is None or callable(parse_op)), f"provided object: {parsable_object} is not parsable"
self.parsable_object = parsable_object
self.default_ret = default_ret
def _parse_args(self, key: str, trace: list, parent=None, **kwargs):
try:
logger.debug(f"Parsing member {' -> '.join(trace)}: {key}...")
if key not in kwargs:
return self.default_ret, True
val = kwargs[key]
if self.parsable_object is not None:
return self.parsable_object()._parse_args(key, trace, parent, **kwargs)
return val, True
except Exception as e:
logger.error(f"Unknown error occurred while parsing trace:\n{' -> '.join(trace)}")
logger.error(str(e))
return None, False
class RequiredMember(OptionalMember):
def __init__(self, parsable_object=None):
super().__init__(parsable_object)
def _parse_args(self, key: str, trace: list, parent=None, **kwargs):
try:
if key not in kwargs:
logger.error(f"Could not find required parameter: {key} with trace:\n{' -> '.join(trace)}")
return None, False
return super()._parse_args(key, trace, parent, **kwargs)
except Exception as e:
logger.error(f"Unknown error occurred while parsing trace:\n{' -> '.join(trace)}")
logger.error(str(e))
return None, False
The load-bearing detail is the return type: (value, success), always, never an
exception escaping a normal validation failure. OptionalMember with no key present
just returns its default and True, which is how name and description are allowed to
be absent from every config in configs/*.yaml without a warning. RequiredMember with
no key present logs the specific field name and returns False. Neither raises.
IterableMember applies another member to every item of a list, ANDing every item’s
success into one flag for the list:
# segtester/configs/base.py:58-90 (trimmed)
class IterableMember:
def _parse_args(self, key: str, trace: list, parent=None, **kwargs):
conf_list = kwargs.get(key, [])
ret = []
success = True
try:
for i, item in enumerate(conf_list):
key_it = str(i)
if self.parsable_object is not None:
item, succ_it = self.parsable_object()._parse_args(key_it, trace + [key_it], parent, **{key_it: item})
success = success and succ_it
ret.append(item)
return ret, success
except TypeError:
logger.error(f"Expected iterable object:\n{' -> '.join(trace)}")
return None, False
And MappableMember is the one that makes a single tests: list able to hold five
completely different shapes of object (an ExecuteAlg, a Segmentation3d, an
OdometryAssessment and so on, each with its own required fields), by branching on an
id: key:
# segtester/configs/base.py:93-119 (trimmed)
class MappableMember:
def __init__(self, type_map: dict):
self.type_map = type_map
def _parse_args(self, key: str, trace: list, parent=None, **kwargs):
val = kwargs[key]
if not isinstance(val, dict):
logger.error(f"Expected a dictionary for {key}, trace: {' -> '.join(trace)}")
return None, False
if val.get('id') not in self.type_map:
logger.error(f"Expected id to be one of: {set(self.type_map.keys())}\n -> trace: {' -> '.join(trace)}")
return None, False
return self.type_map[val.get('id')]()._parse_args(key, trace, parent, **kwargs)
Stack those three together: RequiredMember(IterableMember(MappableMember(RUNNABLE_MAP))),
and you get “tests: must exist, must be a list, and every item in it must be a dict
whose id: names one of seven registered runnable types, each validated against its own
schema.” One line encodes all of that. configs/runnable/__init__.py builds the map the
same way the other two dispatch tables (algorithms, datasets) are built: a plain dict
literal, populated by each config class registering itself:
# segtester/configs/runnable/__init__.py:10-18
RUNNABLE_MAP = {}
ExecuteAlgConfig.register_type(RUNNABLE_MAP)
ViewDatasetConfig.register_type(RUNNABLE_MAP)
VisualisePredictionsConfig.register_type(RUNNABLE_MAP)
OdometryConfig.register_type(RUNNABLE_MAP)
Segmentation3d.register_type(RUNNABLE_MAP)
Segmentation2dReproj.register_type(RUNNABLE_MAP)
SummarizeRes.register_type(RUNNABLE_MAP)
There’s a fifth member class, ConstantMember (base.py:47-55): it always returns its
constructor value and success=False, which looks deliberate (maybe meant to force a
warning wherever it’s used?), but I never actually used it anywhere in the codebase. It’s
dead code, and a fun one to notice while transcribing this: a class that exists in the
schema diagram and is never instantiated anywhere in the repository.
Fail loudly at parse time, quietly at run time
Every member _parse_args returns (value, success). ConfigParser._parse_args (the
method every config class shares by inheriting from ConfigParser) walks its own
__dict__, calls _parse_args on every member, and ANDs every one of those flags
together:
# segtester/configs/base.py:136-165
def _parse_args(self, key, trace: list, parent=None, **kwargs):
try:
curr_config = kwargs[key]
if not isinstance(curr_config, dict):
logger.error(f"Expected a dictionary for {key}, trace: {' -> '.join(trace)}")
return None, False
kwarg_keys = set(curr_config.keys())
parse_success = True
for key, value in self.__dict__.items():
parse_op = getattr(value, "_parse_args", None)
if not callable(parse_op):
logger.warn(f"Parameter: {key} with trace:\n{' -> '.join(trace)} is not parsable... Skipping!")
continue
kwarg_keys.discard(key)
res_val, success = value._parse_args(key, trace + [key], self, **curr_config)
parse_success = parse_success and success
setattr(self, key, res_val)
if len(kwarg_keys) > 0:
logger.warn(f"Skipping unknown keys: {kwarg_keys}\n -> in config file: {' -> '.join(trace)}")
self.parent_config = parent
return self, parse_success
except Exception as e:
logger.error(f"Unknown error occurred while parsing trace:\n{' -> '.join(trace)}")
logger.error(str(e))
return None, False
Nothing in that loop returns early on a failure: a missing base_result_path doesn’t
stop the loop from also checking tests, and a bad id: three levels deep in the third
test doesn’t stop the fourth test from being checked too. The flag just keeps getting
ANDed down. The only place this ever turns into an exception is right at the top level,
once, after everything has been walked:
# segtester/configs/base.py:167-173
def parse_from_config(self, init_trace: str, **kwargs):
trace = []
if init_trace is not None:
trace += [init_trace]
_, success = self._parse_args(init_trace, trace, **{init_trace: kwargs})
assert success, "Invalid configurations detected please look at the logs."
return self
One assert, at the very end, after the logger has already printed every error found
anywhere in the tree. That’s “fail loudly at parse time,” loudly meaning all at once,
not meaning immediately. Compare that to what happens once parsing has actually
succeeded and the platform starts running: RunnableConfig.__call__ wraps each
runnable’s call in its own try/except, and a broken one just gets logged and skipped
so the rest of the list still runs:
# segtester/configs/runnablebase.py:17-26
def __call__(self, *args, **kwargs):
logger.info("Starting Evaluation")
for i, test in enumerate(self.tests):
logger.info(f"Running test {i}/{len(self.tests)}")
logger.info(SEP)
try:
test(base_result_path=self.format_string_with_meta(self.base_result_path))
except Exception as e:
logger.error(f"An unknown error has occured. Skipping test {i}")
logger.error(f"{e}")
That’s “quiet at run time”: an unhandled exception here doesn’t crash the process, it
just costs you that one runnable. The same pattern repeats one level down, per scene,
inside every assessment and algorithm driver: segtester/assessments/segmentation3d.py
wraps its whole per-scene body in a try, and on any exception, logs it and moves to the
next scene instead of aborting the run:
# segtester/assessments/segmentation3d.py:134-139
except Exception as e:
curr_gt_scene_id = None # reload gt scene incase there is an error
logger.error(f"Exception when performing 3d seg assessment on {est_dataset_conf.id}:{scene.id}. "
f"Skipping scene and moving on...")
logger.exception(e)
Put together: config errors are checked exhaustively and stop the run before it starts; runtime errors are caught narrowly and only cost you the one runnable or the one scene that hit them. Two failure modes, two deliberately different philosophies, and the code draws the line between them at exactly the moment a multi-hour GPU job would otherwise be one bad scene away from losing everything after it.
The schema, and what each runnable actually needs
assets/eval_platform_design_part_2.pdf draws the dispatch: one Base Config, five
runnable shapes hanging off tests:. Redrawn as a diagram of the relationship: the
full field lists are transcribed as tables underneath, not squeezed into SVG text:
The five names are the actual RUNNABLE_MAP keys, not the PDF’s prose labels (“3D
Segmentation Ass.” etc., same boxes). Two more runnable kinds exist in code but not
in the PDF: ViewDataset and VisualisePredictions, both added after the diagram was
drawn. The widget below has all seven.
Runnable (id:) | Required | Optional (default) |
|---|---|---|
ExecuteAlg | alg (dispatch on ALG_MAP), dataset (dispatch on DATASET_MAP) | none |
Segmentation3d | gt_dataset, est_dataset, label_map, label_map_dest_col | skip_existing (True), save_path ({dataset_id}/{scene_id}/{alg_name}/seg/seg3d), point_dist_thresh (0.5) |
Segmentation2dReproj | same four as above | skip_existing (False), save_path (.../seg/seg2d), point_dist_thresh (0.05), use_reprojection (True) |
OdometryAssessment | gt_dataset, est_dataset | alignment_options (["noalign"]), pose_relations (full/translation/rotation), run_ape_tests/run_rpe_tests/create_plots (True), confirm_overwrite (False), save_path (.../odo/{alignment_opt}_{pose_relation}) |
SummarizeRes | est_dataset, label_map, label_map_dest_col, label_map_dest_name_col | dataset_id, save_path (summary/{dataset_id}/) |
ViewDataset | dataset | view_pointcloud (True) |
VisualisePredictions | result_dataset, label_map, label_map_dest_col, label_map_name_col | skip_existing (False), pause_on_scene (False), save_path (.../seg/vis) |
gt_dataset/dataset dispatch again, into DATASET_MAP; est_dataset/result_dataset
are always a fixed ResultsConfig (file_map, dataset_id, load_path, save_path),
not dispatched, because a result is always read the same shape regardless of which
algorithm produced it. assets/eval_platform_design_part_3.pdf draws that dataset side:
Every field each DATASET_MAP member needs, direct from segtester/configs/dataset/ {scannet,nyuv2,kitti,results}.py. KITTI is registered in DATASET_MAP and has
never, as far as I can tell from the rest of the repo, been used by any config or
dataloader that reads it back: a second small dead end alongside ConstantMember.
The algorithm side (assets/eval_platform_design_part_4.pdf) is the one place the PDF’s
field lists are genuinely long: SemanticFusion alone carries 17 fields for the CNN and
CRF plus 16 more for the nested ElasticFusion SLAM backend it wraps. A diagram with that
much text in it is the report’s text, cropped, so here it’s a table instead:
alg.id | Required | Selected optional fields (default) |
|---|---|---|
3DMV | model_path, model2d_orig_path | voxel_size (0.05), num_nearest_images (8), process_nth_frame (10), depth_min/depth_max (0.4/4.0), grid_dimX/Y/Z (31/31/62), num_classes (42), skip_existing (True) |
MinkowskiEngine | weights_path | voxel_sizes ([0.05, 0.02]), num_classes (20), alg_name ("MinkUNet34C"), skip_existing (True) |
SemanticFusion | prototext_model_path, caffemodel_path, class_colour_lookup_path | cnn_skip_frames (10), use_crf (False), crf_iterations (10), plus 16 ElasticFusion fields: timeDelta (200), countThresh (35000), errThresh (5e-05), covThresh (1e-05), closeLoops (True), photoThresh (115), confidence (10), depthCut (8), icpThresh (10), fernThresh (0.3095), so3 (True), use_gt_pose (False), and four more booleans (iclnuim, reloc, fastOdom, frameToFrameRGB) |
Every default above is quoted from segtester/configs/alg/{MV3DConfig,MinkowskiEngineConfig, SemanticFusionConfig}.py, and matches eval_platform_design_part_4.pdf field-for-field:
that PDF has no defaults, only names and types, since defaults live only in code.
SafeDict: a path template that formats itself in stages
Every save_path default above has unresolved {placeholders} in it: {dataset_id},
{scene_id}, {alg_name}. None of those are known when the config is parsed: they only
exist once a dataloader has listed real scenes and an algorithm has actually run on one.
So the platform needs to format the same template string more than once, with more
information available each time, and not crash on the placeholders it can’t fill yet.
That’s SafeDict: three lines wrapping dict.__missing__:
# segtester/configs/base.py:122-124
class SafeDict(dict):
def __missing__(self, key):
return '{' + key + '}'
str.format_map calls __missing__ for any key it doesn’t find, instead of raising
KeyError. Make the fallback dict return the literal '{key}' for a key it doesn’t have,
and "{a}/{b}".format_map(SafeDict(a="x")) becomes "x/{b}", partially formatted and
still a valid template for the next format call. ConfigParser wraps this once for
config-level metadata:
# segtester/configs/base.py:203-212
@staticmethod
def safe_format_string(string_template: str, **kwargs):
return string_template.format_map(SafeDict(**kwargs))
def format_string_with_meta(self, string_template: str, **kwargs):
return self.safe_format_string(string_template, **{
**ConfigParser.GLOBAL_META,
**self.meta,
**kwargs
})
GLOBAL_META is just {"timestamp": datetime.now().strftime(...)} (base.py:128-130),
computed once at import time. self.meta is whatever the meta: dict in the YAML file
holds, empty in every committed config, but the mechanism is there for a reader to stash
their own values and reference them from base_result_path. That first stage runs
exactly once, in RunnableConfig.__call__, before any runnable executes
(runnablebase.py:23, quoted above): base_result_path gets {timestamp} and meta
filled in, nothing else. That’s why configs/test2.yaml can write
base_result_path: "/mnt/.../RESULTS/{timestamp}" and have every run land in its own
directory without any extra code.
The second stage happens inside each assessment, once per scene, with the keys that are only known there:
# segtester/assessments/segmentation3d.py:62-65
save_path = self.conf.format_string_with_meta(f"{base_result_path}/{self.conf.save_path}", **{
"dataset_id": gt_dataset_conf.id, "scene_id": scene.id,
"alg_name": scene.alg_name,
})
Two calls, two different sets of kwargs, the same template mechanism, and a placeholder
that neither call supplies just survives unresolved. The playground’s “resolved path
templates” panel reproduces exactly this: it fills in {timestamp} and meta: the way
RunnableConfig.__call__ does, then {dataset_id} and {alg_name} where the config
already names them, and leaves {scene_id} (and {voxel_size}, {alignment_opt},
{pose_relation}) literal, because (this is the platform’s real hard blocker, not this
widget’s) no dataset CSV, .ply, poses.npz or probs.npz is committed to this
repository, so there is no real scene list anywhere to resolve them against.
skip_existing: the output path is the completion marker
The third README sentence, “the platform will skip any test that causes an error during
runtime,” is the try/except per scene, already quoted above. But there’s a second,
quieter form of “skip” that isn’t about errors at all:
# segtester/assessments/segmentation3d.py:67-71
if self.conf.skip_existing and os.path.exists(f"{save_path}"):
logger.warn(f"When getting results for 3d segmentation of {est_dataset_conf.id}->"
f"{scene.id}->{scene.alg_name}, "
f"found existing path {save_path}.\n Skipping this scene...")
continue
If the scene’s already-formatted output directory exists, and skip_existing is true,
skip it: no flag file, no database row, no separate manifest of “what’s done.” The
resolved save_path is both where the result gets written and the question the
platform asks itself on the next run to decide whether to redo the work. Kill a
multi-hour job at scene 40 of 200, fix whatever crashed it, and re-run the exact same
config: scenes 1–39 already have a directory on disk, so they’re skipped in milliseconds,
and the job picks up close to where it stopped. No separate checkpointing system, because
the filesystem the results already live on is the checkpoint.
The same six lines, with the same field name, are duplicated seven times across the
codebase: segmentation3d.py, segmentation2d_reproj.py, segmentation2d_reproj_copy.py
(and a third near-identical _no_infil.py variant), mv3d.py, minkowski.py,
semanticfusion.py and visualise_labels.py, each with its own self.conf.skip_existing
and its own os.path.exists(...) check, none of them sharing a helper. It works, and it’s
not DRY, the kind of duplication that’s obvious in hindsight and easy to have not
noticed while adding the seventh call site under deadline. Segmentation2dReproj’s
default is False rather than the True every algorithm and Segmentation3d use,
presumably because reprojection is fast enough that re-running it isn’t the same cost as
redoing a Minkowski forward pass, but that’s my guess reading it four years later, not
something the code or a comment says.
Two worked examples
configs/run_algs_scannet_val.yaml: three ExecuteAlg runnables sharing one
tests: list, one per algorithm, each dispatching to a different ALG_MAP member and
the same SCANNET dataset:
# configs/run_algs_scannet_val.yaml
---
name: Run scannet validation dataset
description: Run all the algorithms on the validation part of scannet
base_result_path: "/mnt/1C562D12562CEDE8/RESULTS/ActualResults/CheckingMEOutputs"
tests:
- id: ExecuteAlg
alg:
id: MinkowskiEngine
weights_path: "/data/algs/minkowski_runner/weights.pth"
dataset:
id: "SCANNET"
file_map: "/data/datasets/scenenn_val_dataset.csv"
- id: ExecuteAlg
alg:
id: 3DMV
model_path: "/mnt/1C562D12562CEDE8/MODELS/3DMV/scannetv2/scannet5_model.pth"
model2d_orig_path: "/mnt/1C562D12562CEDE8/MODELS/3DMV/scannetv2/2d_scannet.pth"
process_nth_frame: 5
num_nearest_images: 4
voxel_size: 0.048
dataset:
id: "SCANNET"
file_map: "/data/datasets/scenenn_val_dataset.csv"
- id: ExecuteAlg
alg:
id: SemanticFusion
prototext_model_path: "/mnt/1C562D12562CEDE8/MODELS/semanticFusion/nyu_rgbd/inference.prototxt"
caffemodel_path: "/mnt/1C562D12562CEDE8/MODELS/semanticFusion/nyu_rgbd/inference.caffemodel"
class_colour_lookup_path: "/mnt/1C562D12562CEDE8/COMMONDEPS/semanticfusion2/class_colour_scheme.data"
caffe_use_cpu: true
cnn_skip_frames: 10
dataset:
id: "SCANNET"
file_map: "/data/datasets/scenenn_val_dataset.csv"
meta: {}
configs/run_3d_seg_assessment.yaml: a single Segmentation3d runnable, with
skip_existing: True written out explicitly even though it’s already the default, and a
save_path override that starts with ../ (it writes one directory above
base_result_path, which the template mechanism happily allows: SafeDict doesn’t
validate paths, only fills placeholders):
# configs/run_3d_seg_assessment.yaml
---
name: Base Test
description: This test serves as an example to how a config file may look
base_result_path: "/data/results/ActualResults/20191008"
tests:
- id: Segmentation3d
save_path: "../eigen13id/{dataset_id}/{scene_id}/{alg_name}/seg/seg3d"
est_dataset:
file_map: "/data/results/ActualResults/scannet_results_conf.csv"
dataset_id: "SCANNET"
gt_dataset:
id: "SCANNET"
file_map: "/data/datasets/scenenn_val_dataset.csv"
label_map:
csv_path: "/data/results/label_map.csv"
label_map_dest_col: "eigen13id"
skip_existing: True
meta: {}
Both are quoted verbatim, personal filesystem paths included: that really is what’s committed. Nothing in this post runs against them; they’re static YAML.
main.py’s get_config_file() is how these actually got picked at the terminal: glob
configs/*.yaml and user_configs/*.yaml, list ten at a time, type a number:

main.py:1-35. No config validation happens here: the picker just hands the chosen
path to RunnableConfig().parse_from_yaml_file(config_file) (main.py:40), which is
where everything above actually runs.
Config playground
A YAML textarea, a validation panel that runs the same JS port of base.py as the tables
above, a run graph that redraws from the same result, and a panel that partially formats
save_path the way SafeDict would. Five presets are the real files from configs/*.yaml:
pick one, edit it, or reach for Break it to inject a missing required field, a
typo’d extra key, or a bad id:, and watch every resulting error and warning get listed
in one pass rather than stopping at the first.
With JavaScript enabled, this becomes an editable YAML textarea with a live validation
panel, a run graph, and a resolved-path-template panel: five real presets from
configs/*.yaml, plus a “break it” menu that injects a missing required field, a
typo’d extra key, or a bad runnable/algorithm/dataset id:.
tests:
- id: ExecuteAlg
alg:
id: MinkowskiEngine
weights_path: "...weights.pth"
dataset:
id: "SCANNET"
file_map: "...scenenn_val_dataset.csv"Everything the widget validates against is either quoted from configs/*.yaml verbatim
(the five presets) or transcribed field-for-field from the Python source cited throughout
this post (the schema). Nothing it shows is a simulated dataset, algorithm or result:
there’s no .ply, poses.npz or probs.npz committed anywhere in the repository for it
to load even if it wanted to.
What this pattern is actually for
None of this is specific to 3D vision. RequiredMember/OptionalMember/IterableMember/
MappableMember is a general recipe for “validate a whole tree, collect every problem,
fail once at the end”: the kind of thing that’s worth reaching for whenever a config
error costs more than a few seconds to discover, which for me meant a benchmark that took
long enough to run that finding error #2 by re-running after fixing error #1 wasn’t a
five-second loop, it was losing another slot in a shared GPU queue. SafeDict solves a
narrower but equally general problem (formatting the same template at different points
with different information available at each), and skip_existing solves the oldest
problem in long-running jobs by refusing to invent a new mechanism for it: the answer to
“is this done” was already sitting on disk.
I didn’t write a report to go with this platform, and by December the repository had grown around a “run it, look at the numbers, adjust, run again” workflow rather than a “write up what happened” one, which is its own kind of honesty about what a masters project’s code actually optimises for under a deadline. The parser held up regardless; every config file this post quotes still parses today, four years after I stopped touching this codebase, because the schema was never implicit in a script: it was always written down, in three small classes, right here.