Train a behavior classifier¶
Supervised classification predicts an annotated behavior on frames nobody annotated. It needs labels: see Import behavior annotations first.
Four classifiers ship. All are features, so all cache and take a run_id.
| Feature | Reads | Install |
|---|---|---|
xgboost |
per-frame feature columns | base install |
sequence-classifier |
per-frame feature columns, modeled as sequences by a registered network | [deep-learning] |
lightning-action |
per-frame feature columns, modeled as sequences | [lightning-action] |
feral |
video directly, through a V-JEPA backbone | [feral], in its own environment |
probability-ensemble is a step beside them. It averages or blends the probabilities of
several classifier runs (Average and blend classifiers).
The XGBoost route¶
Two steps: draw a labeled, class-balanced sample with a held-out test split, then fit.
from mosaic.behavior.feature_library import (
ExtractLabeledTemplates, GroundTruthLabelsSource, XgboostFeature,
)
from mosaic.core.pipeline.types import Inputs
labeled = ExtractLabeledTemplates(
Inputs((features_result,)),
params={
"labels": GroundTruthLabelsSource(),
"n_per_class": 500,
"test_fraction": 0.2,
},
)
labeled_result = ds.run_feature(labeled)
xgb = XgboostFeature(
Inputs((features_result,)),
params={
"templates": ExtractLabeledTemplates.LabeledTemplatesArtifact().from_result(labeled_result),
"strategy": "multiclass",
"default_class": 3,
"n_estimators": 400,
"max_depth": 6,
},
)
xgb_result = ds.run_feature(xgb)
n_per_class caps how many frames each behavior contributes, so a rare behavior is not
drowned by a common one. test_fraction holds sequences back, not frames — a split by
frame leaks, because neighboring frames of one bout are nearly identical.
default_class names the majority or "other" class.
Templates are drawn only from labeled rows. A row no annotation covers is unlabeled
(-1) and is never sampled. On a pair feature, each row takes the label of its own
direction. A CalMS21 label describes the resident's behavior toward the intruder, so
CalMS21 pair features train on the resident-focal rows, and the intruder-focal rows
are unlabeled. See Labeled, background and unlabeled
frames.
strategy="multiclass" fits one model over all behaviors. "one_vs_rest" fits one
model per behavior, which is what you want when behaviors can co-occur.
n_per_class also takes a mapping from class to count, where null takes every
labeled frame of that class: {"0": 6000, "1": null, "2": 6000}. A class the mapping
leaves out contributes nothing. With pool.allocation set to "exact", each sequence
draws its share of a class's count in proportion to the frames it holds. class_weight
is "balanced" by default (each frame weighs inversely to its class's frequency), null
for equal weights, or a mapping from class to weight for the classes to weigh up or
down. n_jobs limits the threads of a fit and does not enter the run id. device is "cpu"
by default, and "cuda" fits on a GPU where the machine has one. It enters the run id,
and the fitted model applies on any machine. The template
draw and the fit each refuse a sample that would not fit in the available memory, and
the message says what to lower.
Train and score in one request¶
A recipe can run the whole route at once, from the raw files to a score on held-out groups. Step scopes make the split: each one narrows a single step to part of what the request covers, so the fitting steps see the training groups and the scoring step the test group.
{
"schema_version": 1,
"name": "supervised",
"steps": [
{"id": "tracks", "type": "op", "kind": "convert-tracks",
"params": {"source_format": "calms21_npy"}},
{"id": "labels", "type": "op", "kind": "convert-labels",
"params": {"source_format": "calms21_npy"}},
{"id": "egocentric", "type": "feature", "feature": "pair-egocentric",
"inputs": ["tracks"], "tracks": {"step": "tracks"}},
{"id": "templates", "type": "feature", "feature": "extract-templates",
"inputs": [{"step": "egocentric"}], "params": {"n_templates": 2000}},
{"id": "scaler", "type": "feature", "feature": "global-scaler",
"inputs": [{"step": "egocentric"}], "params": {"templates": {"step": "templates"}}},
{"id": "labeled", "type": "feature", "feature": "extract-labeled-templates",
"inputs": [{"step": "scaler"}],
"params": {"labels": {"step": "labels"}, "n_per_class": 500}},
{"id": "classifier", "type": "feature", "feature": "xgboost",
"inputs": [{"step": "scaler"}],
"params": {"templates": {"step": "labeled"}, "default_class": 3}},
{"id": "evaluate", "type": "feature", "feature": "evaluate-labels",
"inputs": [{"step": "classifier"}],
"params": {"labels": {"step": "labels"}, "perspective": 0,
"average_over": [0, 1, 2]}}
]
}
mosaic pipeline run -m dataset.yaml --recipe @supervised.json \
--step-scope 'templates={"groups": ["train"]}' \
--step-scope 'labeled={"groups": ["train"]}' \
--step-scope 'classifier={"groups": ["test"]}' \
--step-scope 'evaluate={"groups": ["test"]}'
{"step": "labels"}in alabelsfield reads the labels that step converts, so the scores are against that conversion even when the dataset holds another.- CalMS21 labels belong to a pair, so the features are pair features, and
pair-egocentricwrites a row for each direction of a pair."perspective": 0inevaluate-labelsscores the row of each pair that a label marks. A feature of single individuals, such asspeed-angvel, has noperspectivecolumn and takes no such setting. - A step that reads another through
inputsortracksmust be narrowed to entries the other is asked for, and the plan names the step that is not. A params reference carries no such rule: the classifier reads templates drawn from the training groups and is applied to the test group. - Scan the dataset's sources before planning (
mosaic scan): a conversion step whose format has no scanned file is refused.
evaluate-labels writes metrics.json in its run directory: accuracy, a macro F1 over
average_over (here every class but other), precision, recall and F1 per class, and
a confusion matrix, which is also confusion.parquet. Each of its output rows is one
entry's own score, with its true positives, false positives and false negatives per class
(tp_<class>, fp_<class>, fn_<class>). A row no label covers is left out and counted
in n_unlabeled.
perspective chooses which rows of a pair output are scored: one perspective, both
("pooled"), or the mean of a pair's two probability vectors
("mean_probabilities"). It refuses pair rows of both perspectives unless it is set.
The counts add across entries, so mosaic.core.evaluation can score any set of them as
one and put an interval on the score. pool_entry_counts takes the output table and
returns the macro F1 of all its rows together, which is the report's number and not the
mean of the entries' scores. bootstrap_sequences resamples the entries, with the
random_state you pass, and returns percentile intervals for the accuracy, the macro F1
and each class's F1. A report from a run over other entries, such as one fold of a
cross-validation, joins the rest through pool_reports, and aggregate_reports gives
the mean and standard deviation of repeated runs.
Choose a setting by cross-validation¶
Scoring every setting you try on the test groups spends the test groups. cv_folds on
xgboost scores the training sequences instead, each by a model that never saw it.
Changed steps of the recipe above:
{"id": "labeled", "type": "feature", "feature": "extract-labeled-templates",
"inputs": [{"step": "scaler"}],
"params": {"labels": {"step": "labels"}, "n_per_class": 500, "test_fraction": 0}},
{"id": "classifier", "type": "feature", "feature": "xgboost",
"inputs": [{"step": "scaler"}],
"params": {"templates": {"step": "labeled"}, "default_class": 3,
"cv_folds": 5, "cv_seed": 0}}
Scope the classifier and its evaluation to the training groups, with the template steps:
mosaic pipeline run -m dataset.yaml --recipe @cv.json \
--step-scope 'templates={"groups": ["train"]}' \
--step-scope 'labeled={"groups": ["train"]}' \
--step-scope 'classifier={"groups": ["train"]}' \
--step-scope 'evaluate={"groups": ["train"]}'
evaluate-labels over the training groups is now the pooled out-of-fold score, with the
same perspective and average_over as the test score.
- The training sequences of the labeled templates are dealt into K folds by a hash of
cv_seed, group and sequence. The folds differ by at most one sequence, and they are the same for every setting whose templates cover the same sequences:random_state,n_per_classand the model's parameters do not move them. Varyrandom_statefor repeats andcv_seedfor other folds. xgboostfits K models, each on the training rows outside one fold, then the full model on all training rows. A training sequence is scored by the model of its fold. Any other entry, such as a test group, is scored by the full model, the one a run withoutcv_foldsfits. The step takes up to K + 1 times as long as one fit.- Set
test_fractionto 0 on the labeled templates. Any other value moves a share of the training sequences to the template test split, out of every fold's training rows. - The fold of each sequence and its template rows per class (
rows_<class>) are incv_folds.parquetin the run directory. Read it when a class scores badly. A class held by few sequences can be missing from a fold's training rows, and its probability is 0 for that fold's sequences. The fit warns when a class has fewer sequences than folds, or when a fold's training rows hold under half of a class's rows, and it refuses a fold whose training rows hold fewer than two classes, or withuse_smotefewer than six rows of a class that SMOTE oversamples. fit_scope.jsonof the classifier lists the training sequences, as it does for any model fitted on them. Each of them was still scored by a model that did not see it, andcv_folds.parquetsays which.
When you have chosen a setting, widen the same classifier step to the training and test
groups, and score the test groups once. Leave templates and labeled on the training
groups, so that no label of a test sequence enters the sample:
mosaic pipeline run -m dataset.yaml --recipe @cv.json \
--step-scope 'templates={"groups": ["train"]}' \
--step-scope 'labeled={"groups": ["train"]}' \
--step-scope 'classifier={"groups": ["train", "test"]}' \
--step-scope 'evaluate={"groups": ["test"]}'
The scope is not part of the classifier's run id, so this is the same run. The model is a cache hit, and only the test groups are computed. A classifier step scoped to the training and test groups from the start would score the test groups for every setting you try. Keep one classifier step: two steps with the same parameters and different scopes are one run, and can fit at the same time.
The out-of-fold probabilities are inputs a second stage can train on. Stack the
prob_* columns with temporal-stack, draw templates from the stacked table, and fit a
second xgboost with the same cv_seed. Its folds are the first stage's. Name the
columns exactly, as "columns": ["prob_0", "prob_1", "prob_2", "prob_3"], because a
prefix is refused and temporal-stack stacks every payload column otherwise, the
predicted_label among them.
What the score does not cover:
- It is not nested. The full model and the settings you compare were chosen with every training sequence in view, and a second stage is evaluated on the folds its inputs were made on.
- Label-free fits upstream (the scaler, k-means, t-SNE, PCA) see the features of the held-out sequences. The labeled sample is drawn over all training sequences, so a held-out sequence's class totals set how many rows of the other folds are kept. No label of a held-out sequence enters the model that scores it.
- A fold is a set of sequences. If several sequences come from one animal or one recording, a fold that splits them leaks across them, and mosaic does not know. Make that unit one sequence before you draw the templates.
Give each frame its context¶
A single frame rarely identifies a behavior. temporal-stack widens each row into a
window before the classifier sees it:
from mosaic.behavior.feature_library import TemporalStackingFeature
stacked_result = ds.run_feature(TemporalStackingFeature(
Inputs((scaler_result,)),
params={"half": 2, "skip": 1, "use_temporal_stack": True, "sigma_stack": 2, "fps": 30.0},
))
half=2, skip=1 gives each frame the two before and the two after. Feed
stacked_result to ExtractLabeledTemplates and to the classifier in place of the raw
features.
lightning-action and sequence-classifier model the temporal structure themselves,
so they do not need this step. See Model the sequence directly
and Train a sequence network.
Model the sequence directly¶
lightning-action trains a temporal network on frames in order. It reads one individual,
or one ordered pair, at a time: a pair table is split by id1, id2 and perspective,
and each series is trained on and predicted as a sequence of its own. The recipe is the
XGBoost recipe with the classifier swapped and the scaler dropped, because the frames are
standardized by statistics that are stored with the model.
{
"schema_version": 1,
"name": "lightning-action-sequence",
"steps": [
{"id": "tracks", "type": "op", "kind": "convert-tracks",
"params": {"source_format": "calms21_npy"}},
{"id": "labels", "type": "op", "kind": "convert-labels",
"params": {"source_format": "calms21_npy"}},
{"id": "egocentric", "type": "feature", "feature": "pair-egocentric",
"inputs": ["tracks"], "tracks": {"step": "tracks"}},
{"id": "labeled", "type": "feature", "feature": "extract-labeled-templates",
"inputs": [{"step": "egocentric"}],
"params": {"labels": {"step": "labels"}, "n_per_class": 500, "test_fraction": 0}},
{"id": "classifier", "type": "feature", "feature": "lightning-action",
"inputs": [{"step": "egocentric"}],
"params": {"templates": {"step": "labeled"}, "labels": {"step": "labels"},
"default_class": 3, "head": "dtcn", "sequence_length": 500,
"num_epochs": 40}},
{"id": "evaluate", "type": "feature", "feature": "evaluate-labels",
"inputs": [{"step": "classifier"}],
"params": {"labels": {"step": "labels"}, "perspective": 0,
"average_over": [0, 1, 2]}}
]
}
mosaic pipeline run -m dataset.yaml --recipe @sequence.json \
--step-scope 'labeled={"groups": ["train"]}' \
--step-scope 'classifier={"groups": ["train", "test"]}' \
--step-scope 'evaluate={"groups": ["test"]}'
What differs from xgboost:
- The templates name the training entries, and the frames come from the step's
inputs. The model trains on the entries that
labeledassigned totrain, with the labels fromlabels. An entry thatlabeledheld out, or never saw, does not train. The samplelabeleddrew is not read, son_per_classshapes nothing here.weight_classes(on by default) weights the loss by class frequency in its place. The step may read other inputs thanlabeleddid. - The classifier's scope is part of its run id.
xgboostfits from its templates and reuses its model on any entry.lightning-actionfits from the entries in its own scope, so the same step over other entries is another run, and fits again. The step refuses a scope that leaves out a training entry that has labels. To predict more entries with a model you have fitted, name it in a second step with"model": {"step": "classifier"}and notemplatesorlabels.fit_scope.jsonlists every entry of the step's scope, test group included, though only the entries of the train split trained. - A run of consecutive labeled frames is the unit of training. A frame no label covers
ends a run, and so does a gap in the frame numbers. The network reads windows of
sequence_lengthframes from the first frame of a run, so a run shorter than that does not train, and neither do the frames past its last whole window.sequence_lengthmust exceed twice the context the encoder reads on each side of a frame, whichnum_layersandnum_lagsset. The fit names both numbers when it does not. The package validates on a tenth of its windows and warns when it has fewer than ten. - On CalMS21 pair features the resident-focal series is labeled and trains. The intruder-focal series is unlabeled, so it writes no training run, and it is still predicted.
- Every row is predicted. Each series is predicted as a whole, so no row at the end of
an entry falls to
default_class. That class stands only for a row that no class clearsdecision_thresholdfor, and for a row with a missingid1,id2orperspective. - It trains on a GPU when the machine has one, and the step is offered to GPU
workers.
deviceset tocputrains on the CPU.
Train a sequence network¶
sequence-classifier trains a neural network on frames in order and predicts each series
whole. A head from a registry is the network: tcn (dilated convolutions, centered on
each frame), gru (a bidirectional GRU) or mlp (each frame by itself). It needs the
[deep-learning] extra and is offered to a GPU worker.
Its defaults are those of the networks in the CalMS21 Task 1 agent runs, where a
bidirectional GRU and a dilated TCN on 37 per-frame columns scored 0.859 and 0.855 test
macro F1 over attack, investigation and mount, the score of XGBoost on 981 columns. The
recipe is the lightning-action one with the classifier swapped:
{
"schema_version": 1,
"name": "sequence-network",
"steps": [
{"id": "tracks", "type": "op", "kind": "convert-tracks",
"params": {"source_format": "calms21_npy"}},
{"id": "labels", "type": "op", "kind": "convert-labels",
"params": {"source_format": "calms21_npy"}},
{"id": "egocentric", "type": "feature", "feature": "pair-egocentric",
"inputs": ["tracks"], "tracks": {"step": "tracks"}},
{"id": "labeled", "type": "feature", "feature": "extract-labeled-templates",
"inputs": [{"step": "egocentric"}],
"params": {"labels": {"step": "labels"}, "n_per_class": 500, "test_fraction": 0}},
{"id": "classifier", "type": "feature", "feature": "sequence-classifier",
"inputs": [{"step": "egocentric"}],
"params": {"templates": {"step": "labeled"}, "labels": {"step": "labels"},
"default_class": 3, "head": "gru", "cv_folds": 5, "random_state": 42}},
{"id": "evaluate", "type": "feature", "feature": "evaluate-labels",
"inputs": [{"step": "classifier"}],
"params": {"labels": {"step": "labels"}, "perspective": 0,
"average_over": [0, 1, 2]}}
]
}
Choose a setting on the training groups first:
mosaic pipeline run -m dataset.yaml --recipe @network.json \
--step-scope 'labeled={"groups": ["train"]}' \
--step-scope 'classifier={"groups": ["train"]}' \
--step-scope 'evaluate={"groups": ["train"]}'
Then widen the classifier to the test groups and score them once:
mosaic pipeline run -m dataset.yaml --recipe @network.json \
--step-scope 'labeled={"groups": ["train"]}' \
--step-scope 'classifier={"groups": ["train", "test"]}' \
--step-scope 'evaluate={"groups": ["test"]}'
- The second command fits again. The classifier fits from the entries of its own
scope, so its scope is part of its run id, and the wider step is a new run. Expect about
the same time again, about 5 minutes per seed with five folds on a GPU (an estimate).
xgboostfits from its templates and keeps its run id, as the cross-validation section above shows. Splitting fitting from applying into two steps, which is planned for every classifier, removes the refit. - The templates name the training entries, and the frames come from the step's
inputs. As with
lightning-action, the sample thatlabeleddrew is not read, an entry it held out never trains, and the step refuses a scope that leaves out a training entry that has labels. Withtest_fractionabove 0, the sequences the sample sets aside are neither trained on nor dealt into a fold, and the full model scores them. Leave it at 0 to cross-validate every training sequence. - Heads and their options.
headistcn,gruormlp, andhead_configholds the options of that head:hidden,blocks,kernel_sizeanddropoutfortcn,hidden,layers,bidirectionalanddropoutforgru, andhidden,layersanddropoutformlp. An option the head does not have is refused. Atcnreads(kernel_size - 1) * (2 ** blocks - 1) + 1frames around each frame, 127 by default. - Name the columns.
columnslists the payload columns the network reads. Narrow sets usually win: 37 columns beat 562 to 999 by 0.04 of cross-validated macro F1. Unset, every payload column of the inputs is read. - A run of consecutive labeled frames is the unit of training. A frame no label covers
ends a run, and so does a gap in the frame numbers. A batch is
batch_sizerandom crops of up tocrop_lengthframes of whole runs, drawn in proportion to the run's length, and a run shorter thanmin_rundoes not train. The loop is AdamW under a one-cycle schedule (learning_rate,weight_decay) forstepsbatches, with square-root class weights (class_weight), and the weights of the last step are kept. There is no validation split. - A series is predicted whole. The two perspectives of a pair table are two series, a
series is cut where its frames are not consecutive, and a row of no series takes
default_class. Frames are standardized by the training frames' mean and standard deviation, which the model stores. cv_foldsworks as forxgboost. The training entries are dealt into folds by the same hash ofcv_seed, group and sequence, so two classifiers on one templates run share their folds. One network is fitted without each fold, and the full model last. A training entry is scored by the network of its fold, and any other entry by the full model.cv_folds.parquetlists the fold of every entry and its labeled frames per class.- Device and repeatability.
deviceiscudaorcpuand is part of the run id, because a GPU and a CPU need not give one model. A model fitted on a GPU applies on a CPU (apply_device, not part of the run id). A CPU fit repeats at one thread count (n_threads, 1 by default), and a GPU fit repeats on the same machine. The torch and CUDA versions are recorded in the model's bundle. - A head of your own. Register one in Python with
register_head(HeadSpec(...)), giving its name, the model of its options and a function that builds a torch module from the options, the number of input columns and the number of classes. Raisehead_revisionwhen its code changes, so that the change is a new run. A head registered in Python exists only in that process. The command line and a queue's workers start their own, and cannot name it.
Average and blend classifiers¶
probability-ensemble combines the prob_<class> columns of two or more classifier runs
and writes prob_<class> and predicted_label as a classifier does. weights is
uniform (an average, such as the seeds of one model), a list with one weight per input,
or fit, which chooses the weights that maximize the pooled macro F1 over score_classes
on the out-of-fold probabilities of the training entries. It searches a grid of
grid_step over the weights and takes the first maximum, and it refuses a member fitted
without cv_folds, so that no in-sample probability picks a weight.
Averages of cross-validated runs are still out of fold, and the step writes their fold table, so a blend can take an average as a member. Two seeds of each of two networks, the averages of each pair, and a blend of the averages:
{
"schema_version": 1,
"name": "blend",
"steps": [
{"id": "tracks", "type": "op", "kind": "convert-tracks",
"params": {"source_format": "calms21_npy"}},
{"id": "labels", "type": "op", "kind": "convert-labels",
"params": {"source_format": "calms21_npy"}},
{"id": "egocentric", "type": "feature", "feature": "pair-egocentric",
"inputs": ["tracks"], "tracks": {"step": "tracks"}},
{"id": "labeled", "type": "feature", "feature": "extract-labeled-templates",
"inputs": [{"step": "egocentric"}],
"params": {"labels": {"step": "labels"}, "n_per_class": 500, "test_fraction": 0}},
{"id": "gru_1", "type": "feature", "feature": "sequence-classifier",
"inputs": [{"step": "egocentric"}],
"params": {"templates": {"step": "labeled"}, "labels": {"step": "labels"},
"default_class": 3, "head": "gru", "cv_folds": 5, "random_state": 1}},
{"id": "gru_2", "type": "feature", "feature": "sequence-classifier",
"inputs": [{"step": "egocentric"}],
"params": {"templates": {"step": "labeled"}, "labels": {"step": "labels"},
"default_class": 3, "head": "gru", "cv_folds": 5, "random_state": 2}},
{"id": "tcn_1", "type": "feature", "feature": "sequence-classifier",
"inputs": [{"step": "egocentric"}],
"params": {"templates": {"step": "labeled"}, "labels": {"step": "labels"},
"default_class": 3, "head": "tcn", "cv_folds": 5, "random_state": 1}},
{"id": "tcn_2", "type": "feature", "feature": "sequence-classifier",
"inputs": [{"step": "egocentric"}],
"params": {"templates": {"step": "labeled"}, "labels": {"step": "labels"},
"default_class": 3, "head": "tcn", "cv_folds": 5, "random_state": 2}},
{"id": "gru", "type": "feature", "feature": "probability-ensemble",
"inputs": [{"step": "gru_1"}, {"step": "gru_2"}], "params": {"default_class": 3}},
{"id": "tcn", "type": "feature", "feature": "probability-ensemble",
"inputs": [{"step": "tcn_1"}, {"step": "tcn_2"}], "params": {"default_class": 3}},
{"id": "blend", "type": "feature", "feature": "probability-ensemble",
"inputs": [{"step": "gru"}, {"step": "tcn"}],
"params": {"default_class": 3, "weights": "fit", "labels": {"step": "labels"},
"score_classes": [0, 1, 2]}},
{"id": "evaluate", "type": "feature", "feature": "evaluate-labels",
"inputs": [{"step": "blend"}],
"params": {"labels": {"step": "labels"}, "perspective": 0,
"average_over": [0, 1, 2]}}
]
}
Screen on the training groups:
mosaic pipeline run -m dataset.yaml --recipe @blend.json \
--step-scope 'labeled={"groups": ["train"]}' \
--step-scope 'gru_1={"groups": ["train"]}' \
--step-scope 'gru_2={"groups": ["train"]}' \
--step-scope 'tcn_1={"groups": ["train"]}' \
--step-scope 'tcn_2={"groups": ["train"]}' \
--step-scope 'gru={"groups": ["train"]}' \
--step-scope 'tcn={"groups": ["train"]}' \
--step-scope 'blend={"groups": ["train"]}' \
--step-scope 'evaluate={"groups": ["train"]}'
Then widen the networks, the averages and the blend to the training and test groups, and score the test groups:
mosaic pipeline run -m dataset.yaml --recipe @blend.json \
--step-scope 'labeled={"groups": ["train"]}' \
--step-scope 'gru_1={"groups": ["train", "test"]}' \
--step-scope 'gru_2={"groups": ["train", "test"]}' \
--step-scope 'tcn_1={"groups": ["train", "test"]}' \
--step-scope 'tcn_2={"groups": ["train", "test"]}' \
--step-scope 'gru={"groups": ["train", "test"]}' \
--step-scope 'tcn={"groups": ["train", "test"]}' \
--step-scope 'blend={"groups": ["train", "test"]}' \
--step-scope 'evaluate={"groups": ["test"]}'
The second request fits every network again, for the reason given above.
- A member of a fitted blend may be any classifier run that writes
cv_folds.parquet,xgboostincluded: add{"step": "classifier"}to the blend'sinputsbeside the averages. The members must write the same classes and deal the same folds, so give them one templates run and onecv_seed. The step compares the folds,group,sequenceandfold, and not the counts, becausexgboostcounts sampled template rows wheresequence-classifiercounts labeled frames. - The blend's score over the training entries is optimistic by the freedom of its weights,
one fewer than the number of members.
ensemble_report.jsonin the run directory gives the weights, the objective, the objective of each member alone and of the uniform average. The held-out score is the test one. - The grid has a limit of 20,000 weight vectors. Average the seeds of each model first, as above, and blend the averages. Six members at the default step of 0.05 would be well over the limit.
weightsas a list oruniformneeds no labels and no folds, and the run does not depend on its scope.
Read the predictions¶
The classifier writes its predictions into features/xgboost__.../<run_id>/, carrying
the input's identity columns through unchanged. On pair-level input that includes
perspective, so the predictions join one-to-one back against the features they were
trained on — the embedding, the clustering, anything else at that level. See What
identifies a pair row.
To fit or score in your own code, read the run as an array. load_run_matrix fills
one array with the columns you name, instead of building a frame of every column:
from mosaic.core.pipeline import load_run_matrix
rows = load_run_matrix(ds, xgb_result, columns="prob_*", perspective=0)
rows.index # group, sequence, frame and id (id1, id2 and perspective on pair output)
rows.values # float32, one row for each row of rows.index
rows.columns # ("prob_0", "prob_1", ...)
columns takes a name, a glob or a list. scope= narrows the entries, perspective=
keeps one ordering of a pair, and dtype= replaces float32. Rows come in order of
group and sequence. The call refuses a run that is not finished, a scope that names an
entry the run lacks, and a read that does not fit in the available memory, and the
message says how to narrow the read. allow_partial=True reads what the run holds:
rows.entries is what was read, rows.finished is False for a run that lost an entry
or is still going, and rows.missing lists the entries of the scope that have no file.
For the label of each row, use
align_predictions_to_labels.
Draw them onto the video to see what the model learned:
from mosaic.behavior.visualization_library import Overlay
ds.run_feature(Overlay(Inputs((xgb_result,)), params={"color_by": "predicted_label"}))
FERAL¶
feral skips the feature chain and classifies from video through a V-JEPA backbone.
It runs in mosaic's own process but wants its own environment, because it pins exact
dependency versions — see installation.
Worked example¶
calms21-template.ipynb runs this whole path on CalMS21: features →
wavelets → scaler → temporal stack → labeled templates → XGBoost, then draws the
predictions back onto the t-SNE embedding.