Skip to content

Estimator recipes

myogestic.recipes.estimators ships constructor recipes for third-party estimators - thin wrappers that return a fitted-or-fittable object (.fit(X, y) + .predict(X)) with sane defaults. The library never owns the model lifecycle; that stays in your @pipeline.train. Optional dependencies are imported lazily, and each constructor raises a clear ImportError naming the extra to install.

To persist a trained model, use myogestic.ml.save_pickle / load_pickle (see the ML API).

CatBoost

catboost_classifier

catboost_classifier(**kwargs: Any) -> Any

CatBoostClassifier with quiet defaults.

Examples:

>>> from myogestic.recipes.estimators import catboost_classifier
>>> model = catboost_classifier(iterations=100)

catboost_regressor

catboost_regressor(**kwargs: Any) -> Any

CatBoostRegressor with quiet defaults.

Examples:

>>> from myogestic.recipes.estimators import catboost_regressor
>>> model = catboost_regressor(iterations=200, loss_function="MultiRMSE")

scikit-learn

sklearn_classifier

sklearn_classifier(**kwargs: Any) -> Any

RandomForestClassifier (sklearn).

Examples:

>>> from myogestic.recipes.estimators import sklearn_classifier
>>> model = sklearn_classifier(n_estimators=200, random_state=0)

sklearn_regressor

sklearn_regressor(**kwargs: Any) -> Any

RandomForestRegressor (sklearn).

Examples:

>>> from myogestic.recipes.estimators import sklearn_regressor
>>> model = sklearn_regressor(n_estimators=200, random_state=0)

sklearn_extra_trees_classifier

sklearn_extra_trees_classifier(**kwargs: Any) -> Any

ExtraTreesClassifier (sklearn). Defaults n_estimators=300, n_jobs=-1.

Examples:

>>> from myogestic.recipes.estimators import sklearn_extra_trees_classifier
>>> model = sklearn_extra_trees_classifier()

sklearn_extra_trees_regressor

sklearn_extra_trees_regressor(**kwargs: Any) -> Any

ExtraTreesRegressor (sklearn). Same defaults as the classifier.

Examples:

>>> from myogestic.recipes.estimators import sklearn_extra_trees_regressor
>>> model = sklearn_extra_trees_regressor()

sklearn_logistic_classifier

sklearn_logistic_classifier(**kwargs: Any) -> Any

Multinomial LogisticRegression (sklearn). max_iter=1000 default.

Examples:

>>> from myogestic.recipes.estimators import sklearn_logistic_classifier
>>> model = sklearn_logistic_classifier()

Bidirectional proportional control (zero deps)

One signed command in [-1, +1] for a bidirectional DOF - a wrist going down or up, not a wrist going more or less. Reach for this instead of a plain regressor whenever the target has two directions: overall amplitude says how much, it does not say which way, and a regressor handed raw features will happily learn the wrong one. examples/start_here/pong.py trains it as its default mode.

y is the signed target per window, and both its sign and its magnitude are read: the sign groups the windows into the two directions, the magnitude says how much of a full contraction each one asked for. The effort span is therefore fitted per window as median((total - rest_) / abs(y)) over the windows reaching abs(y) >= 0.5, which is what lets a graded block work and costs a cued block nothing - there every non-rest window has abs(y) == 1 and the two rules return the same float. fit raises if fewer than three windows clear that bar rather than fitting a span on two. Record for proportional control covers what that means for the recording protocol.

directional_decoder

directional_decoder(*, shrinkage: float = 0.1) -> _DirectionalDecoder

Bidirectional proportional control as command = activation x direction. No deps.

Input contract, and it is not optional. Every column of X must be non-negative, must grow with contraction strength, and must answer a gain on the electrodes the same way as every other column. Multiply the signal by g and RMS, MAV and WL all scale by g; VAR scales by g**2 and ZC not at all. So RMS, MAV and WL mix freely (the app default is RMS+MAV) and VAR is fine on its own, but a set spanning two of those groups is not: a mix is exactly what costs you the gain invariance in Notes. Signed or mean-centred features break the split outright: the row sum stops being effort and the normalised row stops being a spatial pattern.

Parameters:

Name Type Description Default
shrinkage float

How far the pooled within-class covariance is pulled toward its own diagonal, 0.0..1.0. A conditioning knob, not a correctness requirement — see Notes.

0.1

Returns:

Type Description
_DirectionalDecoder

Object with .fit(X, y) / .predict(X). y is the signed target in [-1, +1], with 0 marking rest; predict returns one signed command per window, also in [-1, +1].

Notes

Effort and direction do not live in the same place, and one regressor over raw features conflates them. Measured on three 8-channel bracelet recordings (one take each of Down / Rest / Up, 68 windows): overall amplitude barely separates Down from Up at all, d' = 0.52, with total RMS 4091 / 1188 / 3727 and heavily overlapping ranges. The amplitude-normalised pattern separates the very same windows cleanly — per-channel Down-vs-Up d' reaches 10.9 (channel 6) and 8.2 (channel 4), 0-indexed as the viewer labels them.

So amplitude says how much and the spatial pattern says which way. A regressor handed the raw features learns whichever cue is louder in the training set: the shipped CatBoost regressor learned "louder = Down", because Down simply happened to be recorded harder, and its output is therefore non-monotonic in effort — scaling every channel by 1.0 -> 1.3 -> 1.6 moved its Up prediction 1.000 -> 0.882 -> 0.723. Contract harder, the paddle goes down. It was also dead below ~30% effort.

Here the two cues are estimated apart and multiplied. activation is the row total rescaled so rest reads 0 and a full contraction reads 1; direction is the row's unit-sum shape projected onto the Fisher axis from the mean Down shape to the mean Up shape.

The effort span is fitted per window, and that is what lets a graded block work. span_ is median((total - rest_) / abs(y)) over the windows whose target reaches abs(y) >= _STRONG_TARGETnot median(total[y != 0]) - rest_, which is the same number only if every non-rest window is a full contraction. On a myogestic.tracking.Pursuit block the median non-rest window sits at abs(y) = 0.358, so the old rule fitted a span of 1.76 against a true 4.23, activation saturated, and the command pegged at about 40% effort — the paddle stopped answering the subject. Transfer curve over nine held levels: MAE 0.179 and non-monotone (every level past ±0.5 read a hard ±1) under the old rule, 0.084 and strictly monotone under this one, from the identical recording.

It costs the cued protocol nothing, and the algebra of that is exact rather than approximate. There every non-rest window has abs(y) == 1, so abs(y) >= 0.5 selects the same windows as y != 0, dividing by 1 is a no-op, and a median is translation-equivariant — median(total - rest_) == median(total) - rest_. On the three cued blocks both rules return span_ = 4.254340690, the same float. Bit-for- bit sameness needs an odd number of qualifying windows, which is the only place the equivalence is arithmetic rather than algebraic: on an even count numpy.median averages the two middle elements and ((a - c) + (b - c)) / 2 is not (a + b) / 2 - c in float64. Measured over 2000 even-count trials the two rules differ in 34% of them by at most 1.8e-15, and in 0 of 2000 odd-count ones.

_STRONG_TARGET trades bias for count. The division amplifies a window's noise by 1 / abs(y), and an EMG baseline adds to the row total without scaling with effort, so low-target windows read high: on the block above the fitted span runs 4.40 / 4.29 / 4.24 / 4.22 at thresholds 0.25 / 0.50 / 0.75 / 0.90 against the 4.23 reference, on 301 / 165 / 68 / 25 windows. 0.5 caps the noise gain at 2x and lands 1.3% high while keeping a third of the block; 0.75 would shave that to 0.1% at a fifth of the windows, which is the wrong side of the trade for a short recording. _MIN_STRONG_WINDOWS is a floor against nonsense rather than a precision claim — resampling those ratios, the span error is 9% median / 27% at the 90th percentile at three windows against 15% / 46% at one, and a real pursuit block clears the bar 55 times over — 165 qualifying windows against a floor of 3. Below the floor fit raises instead of falling back to a looser threshold: a span quietly fitted on two windows is a decoder that feels wrong on the subject's first trial with nothing in the log to say why.

The direction estimate, by contrast, still uses every non-rest window. A window at abs(y) = 0.05 is shaped almost like rest and has no business in a Down-vs-Up contrast, so restricting it to the same strong windows was measured over five train/probe seed pairs: mean MAE 0.0820 (all) against 0.0812 (strong), a 1% difference that swaps sign across levels — restricting reads ±0.5 better and ±0.25 worse — for two-thirds fewer rows in the covariance. No effect worth a second rule. The asymmetry with span_ is structural: the span divides by abs(y), so a small target directly inflates one estimate, whereas the direction takes a mean of shapes that mid_ and scale_ then affinely rescale, and pulling both class means toward rest shrinks a numerator and its denominator together. A global gain on the electrodes multiplies every column by the same factor — that is what the input contract above buys — so it cancels out of the unit-sum shape exactly, leaves direction untouched and can only raise activation. Break that contract and the cancellation goes with it: ticking VAR (degree 2) beside RMS+MAV (degree 1) on the recordings above walks the mean Up command 0.913 -> 0.901 -> 0.875 -> 0.837 as the gain rises 1.3 -> 3.0, where RMS+MAV holds 0.951 flat to float32 noise. That is the same failure as the regressor above, from the same cause — one number standing in for two.

shrinkage is a conditioning knob, not a correctness requirement. Shape rows sum to 1, so the within-class covariance is singular along the all-ones direction whatever the window count — but that direction is constant across every row, so the component the diagonal ridge (_EPS) leaves w_ pointing along it adds the same offset to every projection and mid_ subtracts it again. Held-out sign accuracy on the recordings above is 100% at every value from 0.0 to 1.0. What it does buy is the other end: at 1.0 the solve is a plain per-feature weighting, which cannot cancel a within-class mode shared across features — a cuff shifting on the arm — the way the full solve can. Raise it toward 1 when n_features runs past the window count and the axis wanders between takes; the 0.1 default keeps nearly all of the solve.

Rest needs no dead-zone hack — activation is 0 at the fitted rest level, so the command is exactly 0 there no matter what the (meaningless) direction estimate says.

Examples:

>>> import numpy as np
>>> from myogestic.recipes.estimators import directional_decoder
>>> down = np.array([[4.0, 4.0, 1.0, 1.0], [3.0, 5.0, 1.0, 1.0]])
>>> up = np.array([[1.0, 1.0, 4.0, 4.0], [1.0, 1.0, 5.0, 3.0]])
>>> rest = np.array([[0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]])
>>> y = np.array([-1.0, -1.0, 1.0, 1.0, 0.0, 0.0])
>>> model = directional_decoder().fit(np.vstack([down, up, rest]), y)
>>> a, b, c = model.predict(np.vstack([down.mean(0), rest.mean(0), up.mean(0)]))
>>> round(float(a), 3), round(abs(float(b)), 3), round(float(c), 3)
(-1.0, 0.0, 1.0)

A far quieter Up contraction still reads Up — loudness sets the magnitude, never the sign:

>>> quiet_up = up.mean(0) / 3.0 + rest.mean(0)
>>> command = float(model.predict(quiet_up[None])[0])
>>> round(command, 2), command > 0.0
(0.26, True)

Dummy estimators (zero deps)

constant_classifier

constant_classifier(class_index: int = 0) -> _ConstantClassifier

Estimator that always predicts class_index. No deps.

Examples:

>>> import numpy as np
>>> from myogestic.recipes.estimators import constant_classifier
>>> model = constant_classifier(1)
>>> model.predict(np.zeros((2, 3), dtype=np.float32)).tolist()
[1, 1]

mean_regressor

mean_regressor() -> _MeanRegressor

Estimator that predicts the mean of training targets. No deps.

Examples:

>>> import numpy as np
>>> from myogestic.recipes.estimators import mean_regressor
>>> model = mean_regressor()
>>> _ = model.fit(np.zeros((2, 1), dtype=np.float32), np.array([1.0, 3.0]))
>>> model.predict(np.zeros((2, 1), dtype=np.float32)).tolist()
[2.0, 2.0]