Command line¶
Every project gets a manage.py. The mlango script does the same job before a
project exists, and python -m mlango works when the script is not on PATH.
The commands¶
Getting started¶
| Command | Does |
|---|---|
mlango startproject NAME [DIR] |
Scaffold a project that already works. --bare skips the demo app |
manage.py startapp NAME |
Scaffold an app: datasets, models, agents, evals, admin, migrations |
manage.py check |
Validate settings, backends, wiring, migrations and the admin |
mlango startplugin NAME --kind trainer |
Scaffold a publishable package that extends mlango |
startplugin needs no project: it writes a distributable package — pyproject
with the entry point already declared, the contract with its interesting parts
commented, a LICENSE and tests — so pip install is all a project needs to reach
it. --kind is trainer, provider, storage or source. See
Extending mlango.
Bringing your own data¶
Django has inspectdb for an existing database. This is the same idea for a
data file: it samples the file and prints a Dataset you can paste into
datasets.py, so your first declaration is an edit rather than a blank page.
python manage.py inspectdata data/reviews.csv
python manage.py inspectdata data/reviews.csv --name Feedback -n 5000
python manage.py inspectdata data/reviews.csv --write --app reviews
Reads .csv, .tsv, .jsonl, .ndjson, .json and .parquet. It needs no
declarations of its own, so it works on a project you have only just created.
class Reviews(Dataset):
"""40 rows, 6 columns."""
id = IntegerField(min_value=1, max_value=40)
body = TextField()
stars = IntegerField(min_value=1, max_value=5)
country = CharField(max_length=16, choices=["GB", "US"])
verified = BooleanField()
label = LabelField(["neg", "pos"])
class Meta:
source = CSVSource("data/reviews.csv")
primary_key = "id"
What it decides, and why:
| Signal | Becomes |
|---|---|
| All values parse as whole numbers | IntegerField with the observed range |
| Any value has a decimal point | FloatField with the observed range |
true/yes/t/on and their opposites |
BooleanField |
| A dict, a list, or a string parsing as either | JSONField |
| ISO timestamps | DateTimeField |
| Few distinct values, and they repeat | CharField(choices=…) |
| Any value longer than 32 characters | TextField |
A column named label, target, y, class… |
LabelField or TargetField |
A unique column named id, uuid or *_id |
Meta.primary_key |
| Some values missing | null=True, required=False |
Two rules worth knowing. Exactly one column becomes a target — declaring two
would leave Model.get_target() unable to choose, so other categorical columns
stay CharField with choices. And a column is only given a max_length when
every sampled value is short, because a limit that turns out to be too small
rejects valid data later, while TextField never rejects anything.
It is a starting point, not an oracle. Anything it guessed at carries a comment saying so, and a column name that cannot be a Python attribute is reported rather than silently mangled.
Data¶
python manage.py dataset list
python manage.py dataset show reviews.Reviews
python manage.py dataset head reviews.Reviews -n 20
python manage.py dataset validate reviews.Reviews
python manage.py dataset materialize reviews.Reviews --notes "nightly snapshot"
python manage.py dataset versions reviews.Reviews
Migrations¶
python manage.py makemigrations [app] [-n NAME] [--dry-run] [--empty]
python manage.py migrate [app] [--plan] [--fake]
python manage.py showmigrations [app]
Training¶
python manage.py train reviews.Sentiment -p C=2.0 -p max_features=5000 \
--tag baseline --notes "first attempt" --materialize
python manage.py sweep reviews.Sentiment -p C=0.25,1,4 \
--strategy grid --metric accuracy --mode max --promote-best production
| Flag | Effect |
|---|---|
-p NAME=VALUE |
Override a hyperparameter. Repeatable |
--dataset LABEL |
Train on a different dataset |
--tag TAG |
Tag the run. Repeatable |
--seed N |
Override the seed |
--materialize |
Freeze the training view into a dataset version first |
--no-register |
Train without adding to the registry |
Prediction¶
Scoring without starting a server. The model comes from the registry, so this runs the same artefact the API would serve.
python manage.py predict reviews.Sentiment "loved every minute of it"
python manage.py predict reviews.Sentiment "great" "awful" --proba
python manage.py predict reviews.Sentiment --dataset -n 100
python manage.py predict reviews.Sentiment --dataset --filter label=pos
python manage.py predict reviews.Sentiment --file incoming.jsonl \
--format jsonl --output scored.jsonl
| Flag | Effect |
|---|---|
--dataset |
Score the model's own declared dataset |
--filter FIELD=VALUE |
Narrow the dataset. Repeatable |
--file PATH |
Score a csv/tsv/jsonl/json/parquet file |
-n N |
Stop after N records |
--version N / --stage NAME |
Which registered version to load |
--proba |
Include class probabilities |
--format table\|jsonl\|csv |
How to print it |
--output PATH |
Write to a file instead of stdout |
An id, uuid or pk on the input is carried through to the output, so a
scored file can be joined back to where it came from. If the data is missing a
feature the model needs, the command says which column is absent and what the
data does have — rather than letting the trainer fail somewhere deep inside a
vectoriser.
Explaining a version¶
Which features a trained version actually leaned on. The weights are recorded on the version row when it is registered, so this reads the metastore and never loads the artifact:
python manage.py explain reviews.Sentiment
python manage.py explain reviews.Sentiment --stage production -n 10
python manage.py explain reviews.Sentiment --json
reviews.Sentiment@v4
top 10 of 40, largest weight first
delightful ████████████████████████████████ 2.4439
dull ████████████████████████████···· -2.1614
brilliant ███████████████████████████····· 2.0495
boring ███████████████████████████····· -2.0407
badly ██████████████████████████······ -1.9794
beautifully ██████████████████████████······ 1.9708
awful █████████████████████████······· -1.8902
excellent █████████████████████████······· 1.8844
waste ███████████████████············· -1.4853
every ███████████████████············· 1.4288
A pipeline's vectoriser names its own columns, which is what turns 40,000 numbered slots into the words above. The sign is the direction of the effect — kept for binary and regression fits, where it means something, and dropped for multiclass, where a feature arguing for one class argues against another.
| Flag | Effect |
|---|---|
--version N / --stage NAME |
Which version to explain (default: newest) |
-n N |
How many features to show |
--json |
Emit the weights instead of a chart |
--recompute |
Load the artifact, re-derive the weights and store them |
--recompute is the escape hatch for a version registered before mlango knew
how to explain it. Backends that cannot name a feature — the neural ones —
report nothing rather than inventing a plausible list.
Diffing two versions¶
Aggregate metrics answer "is the new one better" and hide the answer you are afraid of: a version two points more accurate overall can still have broken forty rows that used to work. This scores both on the same data and diffs the answers.
python manage.py diff reviews.Sentiment 3 4
python manage.py diff reviews.Sentiment # production vs newest
python manage.py diff reviews.Sentiment 3 4 --show-changes 20
python manage.py diff reviews.Sentiment 3 4 --fail-on-regression
reviews.Sentiment v3 → v4 on 500 rows of reviews.Reviews
agreement 94.2%
changed 29 row(s)
neg → pos 18
pos → neg 11
Against the labels
v3 accuracy 0.8840
v4 accuracy 0.9020 +0.0180
fixed 22 row(s) wrong in v3
broke 4 row(s) right in v3
broke is the number nobody reports and everybody wants. A promotion that
improves the average while losing rows that used to work is the kind that gets
reverted a week later, and --fail-on-regression turns it into an exit code you
can put in front of a promotion.
With no version numbers it compares what is in production against the newest — which is the question you have when you are about to promote something.
| Flag | Effect |
|---|---|
--dataset LABEL |
Score a different dataset, e.g. a held-out set |
-n N |
Stop after N rows |
--show-changes N |
Print up to N rows where the two disagree |
--json |
Emit the whole report |
--fail-on-regression |
Exit non-zero if the newer one lost a row the older one got right |
Regression models are compared by distance rather than equality — two float predictions are never equal — so the report gives mean and largest delta, and counts rows that got closer to the truth against rows that got further away.
Unlabelled data still works: the report then says what changed, and does not pretend to say what improved.
This one is not in the admin, and that is deliberate: it loads two models and scores a dataset, which belongs behind a command you chose to run rather than a page that loads when you click a link.
Watching for drift¶
Whether the input has moved away from what a version was trained on. Reads the prediction log, which is off until you turn it on — see Monitoring.
python manage.py drift reviews.Sentiment
python manage.py drift reviews.Sentiment --stage production --since 24h
python manage.py drift reviews.Sentiment --against reviews.Incoming
python manage.py drift reviews.Sentiment --since 24h --fail-on significant
reviews.Sentiment@v4 vs 2841 logged predictions over the last 7d
Column Kind PSI Verdict
----------------- ----------- ------ -----------
text text 0.4132 significant
label (predicted) categorical 0.1801 moderate
--fail-on exits non-zero, which is what makes this usable from a scheduled job
rather than only from a terminal.
Evaluation¶
python manage.py evaluate support.AnswerQuality
python manage.py evaluate support.AnswerQuality --show-failures
python manage.py evaluate support.AnswerQuality --min-pass-rate 0.9
Agents¶
python manage.py agent support.Support # interactive
python manage.py agent support.Support "how do I ...?" # one shot
python manage.py agent support.Support "..." --show-steps # print tool calls
python manage.py agent support.Support "..." --session user-42 # with memory
Inspecting what happened¶
python manage.py runs list --kind train --status finished -n 20
python manage.py runs show 7c8f1020
python manage.py runs compare 7c8f1020 c089b7e6
python manage.py traces list --agent support.Support
python manage.py traces show a1b2c3d4 -v 2
Development¶
python manage.py runserver # 127.0.0.1:8000
python manage.py runserver 8080
python manage.py runserver 0.0.0.0:8080 --reload
python manage.py runserver --no-admin
python manage.py shell # IPython when available
python manage.py shell -c "print(Reviews.objects.count())"
python manage.py test # pytest, against a throwaway metastore
python manage.py test -k splits -x
python manage.py test --coverage
manage.py test points the metastore and artifact store at a temporary
directory for the duration of the run, so a test can never touch real data —
the same idea as Django creating a test database.
Common flags¶
Available on every command:
| Flag | Effect |
|---|---|
--settings MODULE |
Use a different settings module for this run |
-v 0..3 |
Quiet, normal, verbose, very verbose |
--traceback |
Show the full traceback instead of a message |
The shell¶
manage.py shell pre-imports every declared object plus a few helpers:
>>> Reviews.objects.filter(label="positive").count()
1284
>>> Sentiment.versions()
[<ModelVersion reviews.Sentiment@v2 stage=production>, ...]
>>> recent_runs(limit=3)
>>> get_trace("a1b2c3d4").spans
>>> apps.summary()
Your own commands¶
Drop a module in <app>/management/commands/ and it appears in
manage.py help — including one that overrides a built-in, which is how a
project customises train without forking the framework.
from mlango.management import BaseCommand, CommandError
class Command(BaseCommand):
help = "Import reviews from the warehouse."
def add_arguments(self, parser):
parser.add_argument("since", help="ISO date to import from.")
parser.add_argument("--dry-run", action="store_true")
def handle(self, **options):
rows = fetch_since(options["since"])
if not rows:
raise CommandError(f"Nothing to import since {options['since']}.")
self.table(
["id", "subject"],
[[r["id"], r["subject"]] for r in rows[:10]],
)
if options["dry_run"]:
self.warn("Dry run: nothing written.")
return
write(rows)
self.ok(f"Imported {len(rows)} review(s).")
Helpers available on self:
| Helper | Prints |
|---|---|
self.write(msg, level=1) |
A line, respecting -v |
self.ok(msg) / self.warn(msg) |
Green / yellow |
self.stderr(msg) |
To stderr |
self.table(headers, rows) |
An aligned table |
self.style.bold(...) etc. |
Colour, disabled when output is redirected |
Raise CommandError for anything the user should see as a message rather than a
traceback. Set requires_apps = False for a command that must run before apps
load, and requires_settings = False for one that runs without a project.