Contributing a Dataset#
A dataset is one YAML file in emdatabase/index/, validated against
emdatabase/index/json-schema.json. Adding one means adding that file.
Two routes#
Fill in the new-dataset issue form and an action turns it into the YAML file and opens a pull request for you. The Add Dataset page says what to have ready before you start and links to the form.
Or run the CLI below, which asks the same questions in a terminal and writes the file locally, leaving the pull request to you. Both routes end in the same file and both run the same validator. Neither has to be given the checksum or the size: an entry missing either one has the file downloaded and the fields filled in for it.
The issue form takes one URL: the direct link to the file, which is split
into source and file the way the CLI splits it, or kept whole as url
when the file is not served at source/file. A Google Drive share link - the
link the share button copies, file/d/<id>/view or open?id=<id> - is
rewritten to the uc?export=download&id=<id> link that serves the file. The
file name is only asked for when the link does not end in one. Authors is
one author per line, as Name; Affiliation; ORCID, with the ORCID optional.
Techniques#
emdatabase/index/techniques.yaml is the technique vocabulary, and an entry
declares its techniques from it. It has two lists: acquisition, which is how
the data was taken, and ml_task, which is what a model does.
A dataset ticks acquisition techniques only, one or more, and is listed under
each of them. A kind: weights entry ticks one acquisition technique - the
data the model was trained on - and every ML task it performs, at least one.
A technique close to a vocabulary entry fails validation as a misspelling; one
that is nothing like any of them warns and asks for it to be added to
techniques.yaml. Adding a technique means adding it there, in the list it
belongs to and in alphabetical order, with Other staying at the end of
acquisition.
Using the CLI#
python -m emdatabase.new_dataset https://zenodo.org/records/15490547/files/PdNiP.zspy
It splits the URL into source and file - a Google Drive share link is
rewritten to its uc?export=download&id= form first - asks the server for
the file’s size, streams the file to a temporary location to compute its md5 (deleted
afterwards unless you pass --keep), then prompts for the description,
techniques, licence, detector, microscope, voltage, camera length, DOI, tags
and authors. Techniques and tags are comma-separated, so a dataset that is both
in-situ and 4D-STEM is entered as In-situ, 4D-STEM and is listed under
each. The technique prompt prints the vocabulary and asks again for anything
outside it. Anything left blank is omitted. It writes
emdatabase/index/<Name>.yaml and refuses to overwrite an existing file
unless you pass --force.
--checksum md5:... skips the download, --name sets the entry name, and
--out writes somewhere other than the installed package. --yes takes
the defaults and asks nothing, in which case --description is required.
By hand#
Copy emdatabase/index/TEMPLATE.yaml, which carries every field with a note
on its format and whether it is required, and fill it in. Then check it:
python -m emdatabase.new_dataset --validate emdatabase/index/MyDataset.yaml
That prints one line per problem and exits non-zero, or prints valid.
emdatabase.metadata.validate_file() is the same check from Python.
A file inside an archive#
Some data worth shipping is one file inside a multi-gigabyte .zip or .7z
on a record nobody can re-publish, where downloading all of it to get one file is
not reasonable. Such an entry adds an archive block naming the archive and
every file taken out of it:
archive:
url: https://zenodo.org/records/0000000/files/Figures.zip
members:
- member: Figure_01/Panel_a/scan_x128_y128.raw
file: MyDatasetName/scan_x128_y128.raw
checksum: md5:0123456789abcdef0123456789abcdef
size_bytes: 1000000
download() then fetches only those members, over HTTP range requests: a few
requests read the archive’s directory, and only the member’s own bytes follow.
What comes back is a path, as for any other entry. The format is taken from the
link’s file name, so no field declares it.
What that costs differs by format. A zip compresses each member on its own, so
one member is read and streamed directly. A 7z compresses files together in
solid blocks, so reaching a member means decompressing its block from the start:
cheap for a file at the front of a small block, expensive for one at the back of
a large one. It is worth measuring before adding a 7z entry - in the archive
behind MOSS6Fig3 the same 15.3 GB file holds members costing
anywhere from 2 MB to 2.6 GB to reach. Note also that 7z is extracted rather
than streamed, so its progress bar fills in one step at the end and a cancel
cannot interrupt it.
An archive entry leaves out the top-level file, checksum and
size_bytes. The first two are the first member’s, stated there instead so
that no fact about a file appears at two levels; the entry’s size_bytes is
every member’s added up, which is the one number size shows. checksum and
size_bytes directly under archive describe the archive itself, and are
optional. Give member as the complete path inside the archive - one file name
can appear in several of its directories, so a basename alone is ambiguous.
Data that is more than one file lists them all under members. The first is
what download() returns; the rest arrive beside it. Give them a directory of
their own when a reader expects to find them together - an EMPAD .xml names
its .raw by bare file name and opens it next to itself, so
MyDataset/acquisition_12.xml and MyDataset/scan_x256_y256.raw both keeps
the pairing and keeps it clear of identically named members elsewhere.
delete() removes them all, and the size shown in the catalogue counts them.
These entries are written by hand. The two fields CI otherwise fills in would be
taken from the archive rather than from the member, so a pull request leaving the
entry’s own blank is refused rather than guessed at. A member beside it may leave
its checksum out, and is then fetched unverified. download_url, and the download
link on the docs site, point at the archive: the member has no link of its own.
A host that ignores Range and answers with the whole archive fails loudly,
naming the host, rather than quietly pulling gigabytes.
Contributing model weights#
A trained model is an entry like any other, with kind: weights and a
model: block. One entry per model, named without a version
(TutorialUNet): the entry is a family holding every state the weights have
been published in, not a single file.
Save one .pt per model, holding plain primitives and tensors only:
torch.save({"state_dict": model.state_dict(), "config": {"hidden": 256}}, path)
It has to load with torch.load(path, weights_only=True) or it is not
accepted - a pickled nn.Module executes arbitrary code on load and is tied
to the class’s import path. config carries the architecture arguments the
class needs to be rebuilt.
The entry declares model.class (the dotted import path),
model.framework, model.quantem (the versions the checkpoint loads
under), license, and its techniques: the acquisition technique of the data
it was trained on, plus every ML - task it performs. Set the licence from the model’s own terms: a model
trained on a dataset does not inherit that dataset’s licence.
Point the tooling at the link the weights are published at:
python -m emdatabase.new_dataset --kind weights <url>
It asks for the rest and writes two things from that one link: latest,
which follows the link wherever it leads, and versions, holding one dated
snapshot pinned to the md5 the link serves now. --version-date YYMMDD files
that snapshot under a date other than today; the issue form asks for the same
date, and takes today when it is left blank.
Retraining a model means re-uploading the file to the same link, or publishing a new version of the Zenodo record. Nothing in the entry needs editing by hand: the weekly job below sees the new md5, or the new record, and opens a pull request adding the new dated version.
download() returns whatever the latest link serves now. It warns with a
StaleIndexWarning when that md5 is not the one in the index, which happens
whenever the link has moved on since the installed release.
download(version="260902") returns the dated snapshot instead, pinned to its
checksum and failing on a mismatch the way a dataset does.
download() also reads the family’s index file from the main branch,
which the weekly job keeps current, and warns when newer weights are published
there than the installed release knows about; download(refresh=True)
fetches them, pinned to that entry’s checksum. The check_updates config key
turns the check off.
What CI checks#
Every pull request runs the test suite, which validates each YAML file in
index/ (the template included) against the schema, and checks the vendor
names in vendors.yaml and the techniques in techniques.yaml: a name
close to one already on the list fails as a misspelling, while a genuinely new
one warns and asks for it to be added. A weights entry without an ML task, and
a dataset with one, fail as well.
fill_download_fields.yml runs on every pull request that touches
emdatabase/index/, which is to say on hand-edited and CLI-written index
files. It downloads the file behind each changed entry that is missing its
checksum or size_bytes - a weights family’s latest and each dated
version on their own links - fills the fields in and pushes the result back to
the branch. An entry naming an archive is refused instead: each member’s
checksum and size_bytes describe that file inside the zip, and the only
thing there is to download is the whole archive, so they are filled in by hand. A
fork’s branch cannot be pushed to, so a pull request from one
fails instead and prints the values to paste in. An entry coming in through the
issue form is filled in the same way before its pull request is opened, so it
arrives complete.
check_sources.yml runs weekly and asks each source server whether the file
is still there and still the size the entry claims. For an entry fetched out of
an archive it reads the archive’s directory too, over a few range requests, and
checks the member is still there at the size the entry declares.
check_weights.yml runs weekly as well, and what it does depends on where
the family is hosted.
For a link that moves in place, such as Google Drive, it downloads the
latest link and compares the md5 with the index. An unchanged file is
archived on the first run that finds it unarchived, so a newly contributed
entry stops depending on the contributor’s link. A changed file becomes a new
dated version, added in a pull request together with the new latest
checksum. The archive is the weights-archive release on this repository; a
file over 500 MB goes to the workflow run as an artifact instead, and a
maintainer uploads it to the release by hand. A link that answers with
text/html - a Google Drive virus-scan page rather than the file - is
reported and nothing is archived.
For a Zenodo record file, nothing is downloaded and nothing is copied to
GitHub. The job asks the Zenodo API for the newest record of the concept the
current record belongs to. If that is still the record the entry points at, the
run reports it unchanged, and reports an error if the API’s md5 is not the one
in the index. If a newer record has been published and it serves a different
md5, the job adds a dated version - dated by the new record’s publication date -
pointing at the file in that record, and moves latest to it. A newer record
serving the same md5 only moves latest: a record is versioned whole, so
publishing anything beside the weights - a zip of the training data, say - makes
a new id while the weights stand still, and a second date for the same bytes
would claim they had changed. The file it looks for in the new record is the one
whose name matches the current link; if the name has changed and the record
holds more than one file, the run fails rather than guess.
Removing an entry#
Delete the entry’s file from emdatabase/index/, run
python -m emdatabase._create_stubs so emdatabase/data/__init__.pyi no
longer lists it, and open a pull request. Copies people have already downloaded
are not touched. If the weekly weights job archived versions of a weights family
to the weights-archive release, delete those assets too:
gh release delete-asset weights-archive <Family>_<date>.pt
Hosting#
Zenodo is preferred: it gives a DOI, a stable URL and a record that is not going to be rewritten. A GitHub URL is acceptable if it is pinned to a commit SHA. A URL on a moving branch is not, because the file behind it can change without the checksum changing with it.
A weights entry’s latest is the one exception: it is meant to move, because
it follows the model’s published link, and every state that link has served is
kept as a dated version pinned to its own checksum. A dataset, and a weights
entry’s dated versions, stay pinned.
For weights on Zenodo, give the current record’s file link,
https://zenodo.org/records/<id>/files/<name>. A concept DOI has no static
file URL, so the link names one record; the weekly job follows that record’s
concept through the API and, when a new record is published, adds a dated
version pointing at it and moves latest there. Nothing is copied to GitHub:
a Zenodo record’s files are immutable, so the record is already the archive.
GitHub archival is only for links that move in place, Google Drive among them.
Google Drive works for a small file, as a
https://drive.google.com/uc?export=download&id=<id> link written to the
entry’s url; above about 100 MB Drive answers with a virus-scan page
instead of the file, and the entry will not download. Both routes take the
share link as well and rewrite it to that form, and fill in url, source
and file themselves.