Design of Experiments (DOE) is the CheMLFlow layer for defining a set of scientific comparisons before you run them. A DOE file describes the dataset, the evaluation policy, the model and featurization axes to compare, and the output location. CheMLFlow expands that design into runtime configs, filters known-invalid combinations, and records enough metadata to audit what was run.
Use DOE when you want to compare models, featurizers, split strategies, or CV folds under one consistent policy. The point is not only to make many configs quickly; it is to make the experiment easier to trust. A good DOE keeps train, validation, and test policy consistent, prevents hand-edited config drift, keeps artifacts separated, and preserves the reasons that cases were skipped.
Start from the example DOE file:
config/doe.example.yaml
Set at least:
dataset.source.pathdataset.target_columndataset.smiles_columnfor local CSV datasets- the
search_spacevalues you want to compare output.dir
For real experiments, use a fresh output.dir for each DOE run. If you are
rerunning the tutorial output and do not need the old generated files, clean it
first:
rm -rf config/generated/flash_doeGenerate the DOE configs:
conda activate chemlflow_env
python scripts/generate_doe.py --doe config/doe.example.yamlInspect the generated design:
cat config/generated/flash_doe/summary.json
head -n 20 config/generated/flash_doe/manifest.jsonl
head -n 20 config/generated/flash_doe/parent_manifest.jsonl
ls config/generated/flash_doe/*.yamlRun one generated case:
CHEMLFLOW_CONFIG=config/generated/flash_doe/<case_file>.yaml python main.pyUse the config_path values in manifest.jsonl or ls output rather than
assuming a specific generated filename.
Run all valid generated cases locally:
python scripts/run_doe_local.py --doe-dir config/generated/flash_doe --max-workers 1 --resumeThis writes execution_manifest.jsonl and one log per case under
local_logs/. It reads valid execution children from manifest.jsonl; CV
designs still run as one child config per fold/repeat.
For parallel local execution, raise --max-workers and keep --resume:
python scripts/run_doe_local.py --doe-dir config/generated/flash_doe --max-workers 4 --resumeDo not combine --max-workers > 1 with --stop-on-failure.
--stop-on-failure is reserved for serial fail-fast debugging, for example:
python scripts/run_doe_local.py --doe-dir config/generated/flash_doe --limit 1 --stop-on-failureAnalyze local DOE runs without Slurm:
python analysis.py \
--backend local \
--doe-dir config/generated/flash_doe \
--output-dir config/generated/flash_doe/analysis_localLocal analysis reads generated configs, each config's global.run_dir, and
run_status.json/metrics files. It writes the same core audit artifacts as the
Slurm path, including report.json, jobs.csv, all_runs_metrics.csv, and
all_runs_metrics_by_execution.csv.
Top-level keys:
version: must be1dataset: fixed facts about the datadefaults: fixed runtime settings applied to every casesearch_space: experiment axes to varymodel_search: optional model-scoped parent-level hyperparameter expansionconstraints: generation and artifact-safety settingsselection: metric preference metadataoutput: generated artifact location
Typical local CSV DOE:
version: 1
dataset:
task_type: regression
target_column: label
smiles_column: SMILES
source:
type: local_csv
path: local_data/my_data.csv
sample:
fraction: 0.10
seed: 42
strategy: random
defaults:
global.base_dir: data/my_doe
global.random_state: 42
global.runs.enabled: true
split.mode: holdout
split.test_size: 0.2
split.val_size: 0.1
train.tuning.method: fixed
search_space:
train.model.type: [random_forest, xgboost]
split.strategy: [random, scaffold]
pipeline.feature_input: [featurize.rdkit, featurize.morgan]
constraints:
isolate_case_artifacts: true
output:
dir: config/generated/my_doeSet dataset.sample when a DOE should run on a seeded fraction of the raw
dataset before curation, featurization, split fanout, and model search.
strategy: random takes a seeded row sample. strategy: stratified preserves
classification class proportions; for regression it logs a warning and falls
back to random sampling. Classification stratification happens before curation,
so label-normalized datasets stratify on the raw label.source_column.
search_space and defaults use dotted paths. Scalar values are treated as
single-value axes; lists expand into a grid.
Use model_search when a broad DOE should also expand model-specific
hyperparameter candidates. Each resolved hyperparameter point becomes a parent
case; CV fold/repeat executions remain children under that parent. Generated
runtime YAMLs contain concrete train.model.params values, not unresolved search
definitions.
search_space:
train.model.type: [random_forest, dl_simple, chemprop]
split.strategy: [random, scaffold]
pipeline.feature_input: [featurize.rdkit, smiles_native]
model_search:
random_forest:
method: grid
params:
n_estimators: [100, 300, 500]
max_depth: [10, 20, null]
dl_simple:
method: optuna
n_trials: 25
seed: 42
params:
hidden_dim: {type: categorical, choices: [64, 128, 256]}
dropout: {type: float, low: 0.0, high: 0.5}
learning_rate: {type: float, low: 0.01, high: 0.2, log: true}
batch_size: {type: categorical, choices: [32, 64, 128]}
chemprop:
method: optuna
n_trials: 12
seed: 42
params:
max_epochs: {type: categorical, choices: [20, 40]}
batch_size: {type: categorical, choices: [32, 64]}
max_lr: {type: float, low: 0.0005, high: 0.003, log: true}
mp_hidden_dim: {type: categorical, choices: [128, 300]}
ffn_hidden_dim: {type: categorical, choices: [128, 300]}
mp_depth: {type: categorical, choices: [2, 3]}model_search.<model_type> applies only to matching train.model.type cases,
including the profile's default model when train.model.type is omitted.
Unmatched model_search keys are rejected so typos cannot silently drop a
hyperparameter search. model_search currently targets the standard
train.model.params path; train_tdc.model.params search is not supported.
method: grid creates the full cartesian product. method: optuna converts the
YAML distribution specs into Optuna distributions and asks an Optuna study for
n_trials concrete parent candidates at DOE-generation time; each sampled point
becomes a fixed scientific parent before CV fold/repeat fanout. In 5-fold CV,
n_trials: 12 creates 12 scientific parents and 60 execution children for that
matching model branch. The optuna Python package is required for this method.
This is parent-level batch trial generation. A static generate_doe.py call
does not adapt later trials from completed CV metrics; implementing metric-
adaptive Optuna requires an execution loop that runs each parent, aggregates its
CV metric, and reports the result back to Optuna. Runtime child-level tuning
routes such as
train.tuning.method: train_cv, time-series train.tuning.method: optuna, and
train.tuning.use_hpo: true are disabled; generated children should run fixed
concrete train.model.params.
DOE generation writes these files into output.dir:
summary.json: deterministic counts, profile/task, selection metadata, DOE spec hash, snapshot path, and git provenancemanifest.jsonl: one row per attempted execution child, including skipped cases and issue codesparent_manifest.jsonl: one row per scientific parent config*.yaml: one runnable runtime config per valid execution childdoe_spec.input.yaml: the exact DOE spec snapshot used for generation
Generated runtime configs are case-isolated by default. CheMLFlow scopes
global.base_dir and global.run_dir under the DOE spec hash and case_id, and
sets global.runs.id to the case id. Leave
constraints.isolate_case_artifacts: true unless you intentionally want shared
artifacts.
Use scripts/run_doe_local.py when a DOE should run on the current machine
instead of through Slurm. The runner executes only valid execution-child records
from manifest.jsonl and calls main.py with CHEMLFLOW_CONFIG set to each
generated config path.
Common options:
--max-workers N: run up toNcases concurrently.--resume: skip configs whoserun_status.jsonalready saysstatus: success.--limit N: run a short pilot from the valid manifest records.--dry-run: write the execution attempt manifest without runningmain.py.--stop-on-failure: stop after the first failed case.--allow-shared-artifacts: allow parallel execution even when generated configs shareglobal.run_dirorglobal.base_dir. Use only when you know shared artifacts cannot collide.
After a local run, use analysis.py --backend local. Do not fabricate Slurm
logs or sacct output for local work; local analysis combines
execution_manifest.jsonl attempt records with each case's run_status.json
and metrics files.
DOE separates the scientific comparison from execution slices:
- A parent case is the logical scientific config, such as one model, featurizer, and split strategy.
- A child case is one concrete execution, such as a specific CV fold and repeat.
- Holdout designs usually have one child per parent.
- CV and nested designs can have many children per parent.
For split.mode: cv or nested_holdout_cv, keep split.cv.n_splits and
split.cv.repeats in defaults. If fold and repeat indices are omitted, DOE
expands all folds and repeats automatically. Set fold or repeat indices only
when debugging or rerunning a specific execution slice.
Supported profiles are:
reg_local_csvreg_local_csv_ic50reg_chembl_ic50clf_local_csvclf_tdc_benchmark
If dataset.profile is omitted, CheMLFlow infers it from
dataset.task_type and dataset.source.type. For task_type: auto, set
dataset.auto_confirmed: true.
DOE skips combinations that are known to be invalid before they reach runtime. Common examples include:
- model/task mismatches
- split strategy and split mode mismatches
- missing validation split settings
- missing target or SMILES columns
- unsupported feature input for a model
- Chemprop or Chemeleon preprocessing combinations that are not meaningful
- duplicate rendered runtime configs
- runtime schema validation errors
Skipped child cases stay in manifest.jsonl with issue codes such as
DOE_MODEL_TASK_MISMATCH, DOE_SMILES_COLUMN_MISSING, or
DOE_RUNTIME_SCHEMA_INVALID. This is intentional: skipped cases are part of the
audit trail.
If constraints.max_cases is omitted and the expanded design exceeds the
default safety limit of 10,000 execution cases, DOE generation fails fast.
DOE is most useful when it protects the scientific comparison:
- Use the same split policy, random seed, target column, and metric policy across cases.
- Compare models on matched splits where possible.
- Prefer scaffold CV for chemistry generalization claims.
- Avoid picking a winner from many models on one fixed test split.
- Use repeated CV or nested holdout CV when the conclusion needs to be robust.
- Keep a final untouched holdout for final reporting, not iterative selection.
Selection metadata defaults to auc for classification and r2 for regression.
Override with selection.primary_metric when the scientific question requires a
different primary metric.
clf_local_csvwith non-Chemprop models defaults topipeline.feature_input: featurize.morgan.chempropandchemeleondefault topipeline.feature_input: smiles_nativewhen that axis is omitted.reg_chembl_ic50defaultsglobal.target_columntopIC50.- For comparisons across pipelines, strongly consider
split.require_disjoint: trueandsplit.require_full_test_coverage: true.
Keep one DOE file per split mode. Mixing holdout, cv, and
nested_holdout_cv in one cartesian grid makes some settings meaningless for
some rows.
Keep only true experiment axes in search_space; put fixed choices in
defaults. Large mixed grids grow quickly and are harder to interpret.
For chemistry model comparison, prefer split.mode: cv with
split.strategy: scaffold. Scaffold CV requires a usable SMILES column that can
produce canonical_smiles in curated data.
Keep runtime child configs fixed. split.mode: cv or nested_holdout_cv
defines the evaluation fanout; model_search defines hyperparameter candidates
as fixed scientific parents that fan out across the same CV folds.
Run a small pilot before the full DOE. Confirm metrics files, split metadata, runtime, and memory behavior before spending the full compute budget.
Preserve generated configs, manifests, summary.json, and the emitted
git_worktree.patch if generation happened from a dirty working tree. These
files make the experiment reproducible after the run has finished.