Saving computations¶
save and load write a computation to disk and read it back. The default is a
.loman file: a single archive holding a manifest.json that describes the
graph, plus large values stored beside it as binary.
>>> import numpy as np, pandas as pd
>>> from loman import Computation
>>> prices = pd.DataFrame(
... {"px": np.arange(100_000, dtype="float64")},
... index=pd.date_range("2020-01-01", periods=100_000, freq="min"),
... )
>>> comp = Computation()
>>> comp.add_node("prices", value=prices)
>>> comp.save("run.loman")
>>> comp2 = Computation.load("run.loman")
>>> comp2.v.prices.equals(prices)
True
Profiles and containers¶
Two independent choices. The profile decides how a value is encoded; the container decides where the bytes land.
json |
zip (.loman) |
dir |
|
|---|---|---|---|
readable |
one JSON document | zipped JSON | JSON in a folder |
efficient |
not possible | the default | folder + binary blobs |
>>> comp.save("run.loman") # efficient + zip (the default)
>>> comp.save("run.loman", profile="readable") # inline JSON, still zipped
>>> comp.save("run.json") # single JSON document
>>> comp.save("run_dir", container="dir") # same layout, unzipped
The container is inferred from the path — .json means a single document,
anything else means a .loman archive — and load detects it from the file
itself, so you never have to say which you have.
The one combination that cannot work is efficient with a single JSON document,
since there is nowhere to put the out-of-line bytes. It raises and says so.
The efficient profile is still inspectable¶
Only the bulk data moves out of line. Shape, dtype, column names and index type stay in the manifest, so you can see what a saved graph contains without decoding any of it:
>>> import json, zipfile
>>> comp.save("run.loman") # the default: efficient
>>> manifest = json.loads(zipfile.ZipFile("run.loman").read("manifest.json"))
>>> value = manifest["nodes"][0]["value"]
>>> value["index"]["kind"], value["index"]["freq"]
('datetime', 'min')
>>> manifest["blobs"][0]["codec"]
'npy'
For the 100k-row frame above, the manifest is a couple of kilobytes whatever the data weighs.
Which container to use¶
.loman is one file, which is what you want for handing a run to someone else or
uploading it somewhere.
Use container='dir' when you save repeatedly — a checkpointing loop, say.
Updating one value in a zip means rewriting the whole archive, at a cost that
grows with its size; a directory rewrites only the file that changed.
Compression¶
Blobs are compressed with zstd at level 1 by default. You choose the codec; nothing is inferred from the data.
>>> comp2 = Computation()
>>> comp2.add_node("rounded", value=np.round(np.arange(200_000) * 0.01, 2))
>>> comp2.save("rounded.loman")
>>> entry = json.loads(zipfile.ZipFile("rounded.loman").read("manifest.json"))["blobs"][0]
>>> entry["compression"]
'zstd:1'
>>> entry["stored_size"] < entry["size"] // 5
True
To choose something else, pass a profile:
>>> from loman import SerializationProfile
>>> archive = SerializationProfile("archive", inline_max_bytes=8192, compression="zstd:19")
>>> comp.save("small.loman", profile=archive)
>>> fast = SerializationProfile("fast", inline_max_bytes=8192, compression="none")
>>> comp.save("fast.loman", profile=fast)
compression accepts "none", or a codec and optional level: "zstd:1" to
"zstd:22", "zlib:1" to "zlib:9". You can register your own with
loman.serialization.compression.register_codec.
Why zstd, and why on by default¶
Compressing is worth doing without being asked only if finding out costs almost nothing. Measured on 4 MB payloads:
| reject incompressible | realistic price series | |
|---|---|---|
| zlib:1 | 43 MB/s | 8.0× at 291 MB/s |
| zstd:1 | 1067 MB/s | 9.3× at 568 MB/s |
zstd rejects data it cannot compress about 25 times faster than zlib, and compresses real data better and faster besides. That is why it is a required dependency rather than an optional one: it turns "compress and see" from a decision into a non-event, at roughly a second per gigabyte of incompressible data.
What gets stored¶
Whichever is smaller. After compressing, the two sizes are compared and the
smaller one is written; a blob that did not shrink is stored exactly as it came
and recorded as "none":
>>> import os
>>> comp3 = Computation()
>>> comp3.add_node("noise", value=np.frombuffer(os.urandom(500_000), dtype=np.uint8))
>>> comp3.save("noise.loman")
>>> json.loads(zipfile.ZipFile("noise.loman").read("manifest.json"))["blobs"][0]["compression"]
'none'
That is a byte comparison on data already in hand, not an estimate, so there is no threshold to tune. Without it an incompressible blob would be stored slightly larger than raw — codecs add framing — and every future read would pay a decompression step for nothing.
Note
An earlier version of this had an "auto" mode that compressed the first
256 KiB of each blob and extrapolated. It was removed rather than tuned: on
a payload whose character changes part way through — common in market data —
it was wrong in both directions, once projecting a 3.6% saving against an
actual 36.8% and storing the blob raw. With zstd there was nothing left for
it to save.
Optional extras¶
Everything above works with loman's own dependencies: values are stored as
.npy and compressed with zstd, both of which come with loman.
pip install 'loman[efficient]'
adds pyarrow, for storing DataFrames as parquet:
>>> parquet = SerializationProfile("pq", inline_max_bytes=8192, frame_encoding="parquet")
>>> comp.save("run_pq.loman", profile=parquet)
Parquet's value is that other tools can read the blobs directly. It is not
automatically smaller: on a 200k-row price frame the default .npy-plus-zstd
path came out at 1.21 MB against parquet's 1.46 MB, because zstd exploits the
repetition that rounding creates. Measure before assuming. If pyarrow cannot represent a particular frame (duplicate column
names, for instance), the save falls back to the default encoding rather than
failing.
Bring your own storage¶
Sometimes a value does not belong in the saved file at all — a frame that should land in a bucket as parquet, or a result that belongs in a warehouse table. Mark the node with a store name, and supply the implementation when you save and load:
comp.add_node("prices", value=frame, store="warehouse")
comp.save("run.loman", stores={"warehouse": S3Store(bucket="my-bucket")})
comp2 = Computation.load("run.loman", stores={"warehouse": S3Store(bucket="my-bucket")})
The archive then holds only the manifest; prices lives in your bucket, and the
manifest records which store holds it so load knows to ask.
Writing a store¶
A store is two methods. Compression, deduplication, checksums, blob ids and the blob table are all handled for you:
>>> from loman.serialization import BlobStore
>>> class DictStore(BlobStore):
... def __init__(self):
... self.blobs = {}
...
... def write_blob(self, key, data):
... self.blobs[key] = data
...
... def read_blob(self, key):
... return self.blobs[key]
>>> store = DictStore()
>>> comp5 = Computation()
>>> comp5.add_node("prices", value=prices, store="warehouse")
>>> comp5.save("remote.loman", stores={"warehouse": store})
>>> len(store.blobs) > 0
True
>>> zipfile.ZipFile("remote.loman").namelist()
['manifest.json']
>>> Computation.load("remote.loman", stores={"warehouse": store}).v.prices.equals(prices)
True
key is a short relative path such as blobs/0000.npy. A real store would put
it under a prefix of its own; it just has to return the same bytes for the same
key. Override key_for(blob_id, codec, node) to lay keys out differently — by
node name, or partitioned by date.
The file never holds your credentials¶
A manifest records a store's name, never its configuration. No bucket, no connection string, no key ever reaches the file:
>>> manifest = json.loads(zipfile.ZipFile("remote.loman").read("manifest.json"))
>>> manifest["blobs"][0]["store"]
'warehouse'
The consequence is that a file with external values cannot resolve them on its own — whoever loads it supplies the matching store. If they don't, the error says which store is missing and for which node, rather than silently returning a half-loaded graph.
The same applies at save time: a node routed to a store you forgot to pass is an error, not a quiet fallback to writing the data into the file. Believing your data went to S3 when it is sitting in the archive is a worse outcome than a failed save.
Routing at save time instead¶
A node's store= is a default, not a fixture. A profile override wins, so the
same computation can go to a bucket in production and to a plain container in a
test:
>>> local = SerializationProfile("local", inline_max_bytes=8192, overrides={"prices": {"store": None}})
>>> comp5.save("local.loman", profile=local)
>>> "blobs/0000.npy" in zipfile.ZipFile("local.loman").namelist()
True
Selectors match node-key globs ('market/*') or tags ('tag:bulky'), so you can
tag nodes by what they are and let each save decide where that kind of thing
goes.
A readable manifest with the data elsewhere¶
Because an external store is independent of the container, you can combine a plain JSON manifest with out-of-line values:
comp.save("run.json", profile=efficient, stores={"warehouse": S3Store(...)})
The result is a manifest you can read in a text editor, describing values that live in your bucket.
Deduplication¶
Two nodes holding the same object store one blob:
>>> shared = np.arange(50_000, dtype="float64")
>>> comp3 = Computation()
>>> comp3.add_node("a", value=shared)
>>> comp3.add_node("b", value=shared)
>>> comp3.save("shared.loman")
>>> len(json.loads(zipfile.ZipFile("shared.loman").read("manifest.json"))["blobs"])
1
That is identity-based, so two equal but separate arrays are still stored
twice. SerializationProfile(..., dedupe='content') hashes instead, catching
those at the cost of digesting every blob.
Loading untrusted files¶
Loading restores node functions, which means importing the modules the file names — or unpickling a dill blob out of it. Both run code the file chose. The format is not safe against a hostile file, and never was.
allow_code=False skips resolving callables entirely. Values, structure, states
and tags still load, so the graph can be inspected but not recalculated:
>>> comp4 = Computation.load("run.loman", allow_code=False)
>>> comp4.v.prices.shape
(100000, 1)
This is a mitigation for people who know they need it, not a security boundary. Prefer not loading files you do not trust.
File layout¶
Both containers hold the same tree. .loman is it zipped; dir is it on disk:
manifest.json
blobs/0000.npy
blobs/0001.npy
Blob filenames are integers, never node names, which sidesteps / in
hierarchical keys, Windows reserved names and case-insensitive collisions. The
blob table records which node each one came from.
{
"version": 3,
"container": "zip",
"profile": "efficient",
"blobs": [
{"id": 0, "path": "blobs/0000.npy", "codec": "npy",
"compression": "zlib:1", "size": 800128, "stored_size": 98241,
"node": "prices"}
],
"nodes": [ ... ],
"edges": [ ... ],
"metadata": { ... }
}
A value stored out of line keeps its descriptive header inline and replaces only its data:
{"type": "ndarray", "shape": [100000], "dtype": "<f8",
"encoding": "npy", "data": {"$blob": 0}}
The single-document container is the same manifest with "blobs": [], which is
why one load reads all three.
Relationship to write_json¶
write_json and read_json still work, unchanged and undeprecated. They are
save/load restricted to the readable single-document container, and remain
the right choice when you want a text file you can diff.
See Serializing computations for the value
encoding, custom types, and the serialize=False flag — all of which apply to
save too.