Querying and analysis
sillonlab is the notebook and script side of sillon: load a project, filter
its runs, read the data back.
import sillonlab as sl
Loading a project
project = sl.load_project() # the current directory
project = sl.load_project("~/work/sweep")
project = sl.load_project("Shaking Lattice") # by registered name
load_project accepts a name from sl.list_projects() (a unique prefix is
enough), so you do not have to remember paths. See Projects.
Looking around
project.show() # the overview table, same as `sillon list`
project.runs() # a RunCollection of every run
project.runs().list() # just the names
run = project.get("my_fit") # one run, by name, uuid, or uuid prefix
In Jupyter, a project, a run and a collection all render as tables.
Querying
project.query() filters with plain Python. There is no query language to
learn: pass a value to match it, or a callable to test it.
# exact matches
project.query(degree=3)
project.query(tags="baseline", fields={"status": "SUCCESS"})
# predicates
project.query(parameters={"degree": lambda d: d > 2})
project.query(results={"rmse": lambda v: v < 0.1})
# presence
project.query(has_result="coef", has_artifact="mesh", has_tag="gpu")
# time
project.query(after="2026-01-01", before="2026-06-30")
Filters combine with AND:
best = project.query(
tags="sweep",
parameters={"ridge": lambda r: r == 0.0},
results={"rmse": lambda v: v < 5.0},
)
!!! tip "Put cheap filters first"
Filters on parameters, tags, status and dates are answered from the
database. Filters on results and analyses have to open the HDF5 store,
and run only on what the cheap filters left. Narrowing on a parameter
before filtering on a result is what keeps a large project fast.
Working with a collection
query() and runs() return a RunCollection:
runs = project.query(tags="sweep")
len(runs) # how many
runs[0] # by position
runs["my_fit"] # by name or uuid
runs[:5] # a slice, still a RunCollection
for run in runs: ... # iterate
runs.sort_by("rmse") # by a parameter or result name
runs.sort_by("rmse", reverse=True)
runs.sort_by(lambda r: r.runtime)
runs.filter(lambda r: "gpu" in r.tags)
runs.where(has_result="coef") # further narrowing, chainable
runs.show() # print a table
runs.to_dataframe() # pandas
The common question, in one line:
best_five = project.query(tags="sweep").sort_by("rmse")[:5]
Runs missing the sort key sort last, in both directions, so a partially-logged run never displaces a real result. A key that no run has raises instead — otherwise a typo would hand you the first five runs you happened to log and let you call them the best.
!!! warning "Values sort as what they are"
sort_by orders the values as Python does, which bites on numbers logged
as strings — straight off sys.argv, say:
```python
sp.log_param("degree", sys.argv[1]) # "10"
sp.log_param("degree", int(sys.argv[1])) # 10
```
`"10" < "9"` is true, so the first spelling sorts `1, 10, 2, 9` and looks
broken. Log numbers as numbers; to rescue runs already logged, sort on a
conversion:
```python
runs.sort_by(lambda r: float(r.parameters["degree"]))
```
Values that cannot be ordered against each other at all — an array, or an
`int` in one run and a `str` in another — raise and name the key.
Reading a run
run = project.get("my_fit")
run.parameters # {'degree': 1}
run.results # names of the logged results
run.metadata
run.tags
run.notes
run.runtime
coef = run.load_result("coef") # arrays come back from HDF5
run.load_parameter("grid")
run.load_metadata("sillon.python.cwd")
run.load_source() # the script that produced the run
Files:
run.load_artifact("mesh") # path inside the store
run.load_figure("fit")
run.fetch("coef", dest="out/") # copy it out to your own directory
Asking for something a run does not have raises LookupError naming the run —
it never silently returns a placeholder.
DataFrames
df = project.query(tags="sweep").to_dataframe()
df = project.runs().to_dataframe(metadata=True, results=True)
One row per run; columns for name, timestamp, status, runtime, and every
parameter. results=True reads the array store, so it is slower — ask for it
when you want it.
Needs pandas: pip install "sillon[analysis]".
Provenance while you explore
Loading a run's data records it, so that a run you log later in the same process is linked to it automatically — see Provenance. Browsing is not recorded; only actual data loads are.
sl.pending_reads() # runs whose data this session has read
sl.forget_reads() # forget them, so they do not attach to your next run
Annotating after the fact
run.add_tag("publication")
run.add_note("Used in figure 3")
run.add_metadata("reviewed_by", "AL")
Storing derived data
Computed something from a run and want it kept with the run?
spectrum = np.fft.rfft(run.load_result("field"))
run.add_analysis("spectrum", spectrum, used=["field"], method="rfft")
run.load_analysis("spectrum")
run.analyses["spectrum"]["used"] # ['field']
used= records which of the run's values the analysis came from, so a derived
quantity keeps its origin. Omit it and sillon infers it from what you loaded —
see Provenance.
Analyses live beside results in the store and are queryable the same way
(project.query(analyses={"spectrum": ...})).
Finding similar runs
for match in project.like("happy_perlman"):
print(f"{match['score']:.0%}", match["name"], match["changes"])
75% d2_r0.0 {'degree': (3, 2)}
75% d4_r0.0 {'degree': (3, 4)}
50% d2_r0.1 {'degree': (3, 2), 'ridge': (0.0, 0.1)}
Ranked by shared configuration, with a tiebreak on how far the differing values
moved. same_code on each match tells you whether the two runs were produced by
the same code version.
Getting data out
run.export("out/", format="npz") # the arrays: npz, npy or hdf5
run.report("out/") # a write-up for a person to read
run.manifest() # what the run holds
run.sizes() # how much space it takes
To move runs to another machine rather than read them elsewhere, that is a bundle — see Moving runs between machines.
project.export(tags="sweep", dest="/Volumes/share/")
project.import_from("sweep.sillon", dry_run=True)
Comparing runs
d = project.diff("baseline", "refined")
d["code"]["same_logic"] # was this a fair comparison at all?
d["parameters"]["changed"]["degree"] # {"old": 3, "new": 5, "delta_pct": 66.67}
d["results"]["rmse"]["delta_pct"] # -37.11
d["results"]["coef"]["reason"] # "shape"
Results are compared by shape, dtype and hash, never element by element, so
this stays fast on large arrays. Anything above max_bytes (64 MB by default)
is compared by shape and dtype alone and reported as status="unknown" rather
than silently guessed at.
The code block is the one to read first: it says whether the two runs differ
in logic or only in a tuned constant, which decides whether the rest of the
diff means anything.
For a whole sweep:
across = project.diff_across(has_tag="sweep")
across["varying"] # {"degree": [1,2,3,4,5], "ridge": [0.0, 0.1]}
across["constant"] # {"seed": 7, "solver": "lstsq"}
across["logic_versions"] # more than one means they are not all comparable
Code versions and pruning from a notebook
for group in project.versions():
print(group["logic_version"][:8], group["run_count"], group["last_seen"])
current = project.versions()[0]["logic_version"]
clean = project.query(fields={"logic_version": current})
project.prune(before="90d") # free the data, keep the record
project.prune(run_names=["old_run"], keep_metadata=False) # remove it entirely
Both mirror sillon versions and sillon prune — see
Code versions.
Housekeeping
project.rename(run, "better_name")
project.delete_run(run) # removes the row and its stored data
project.diff("run_a", "run_b") # what differs between two runs
project.diff_across(has_tag="sweep") # what varies across a set
project.trace("out/fig.png") # which run produced this file