Configuration¶
Every run — training, evaluation, dataset download — is driven by a Hydra
config under configs/. The files themselves are deliberately bare: a key,
a value, and a section heading. This page is where each key is explained,
including the ones whose values cannot be chosen independently of each other,
and the measurements behind the defaults. If a config file and this page
disagree about what a default is, the file wins; if they disagree about what
it means, this page wins.
Technically: configs/*.yaml are Hydra config groups composed at the
entry-point scripts in tools/, with configs/model/*.yaml selected
through each config’s defaults: list. Any key is overridable on the command
line with dotted paths (uv run python tools/train_beat.py lr=3e-4
data.input=ballroom trainer.max_epochs=40). configs/eval_beat.yaml is the
exception to “config file”: it is also read at import time by
musicality.evaluation and by tools/annotator/inference.py, so its
values are library defaults, not just CLI defaults.
Which config drives what¶
File |
Entry point |
Selects |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
— |
|
|
— |
Beat-phase training (configs/train_phase_beat.yaml)¶
Task and head¶
task—beat_phaseSaved into the checkpoint’s hyperparameters by
BeatPhaseModuleand read back bymusicality.inference.detect_task(), which picks the module class and the postprocessing block at eval/inference time. A checkpoint trained before this field existed cannot be detected and must be retrained.phase_conditioning—beatWhich frames the phase terms are averaged over.
maskEvery frame of a position-annotated track (the original behaviour).
beatOnly frames at or near a beat, weighted by the beat target channel.
beatmatches how the heads are actually read at inference:musicality.postprocess’s bar-position stage samples the phase channels only at detected beat times. Undermask, ~96% of the phase gradient goes into re-learning “is this a beat at all” — which the beat head already does at 0.92 F. Seedocs/beat_phase_improvement_review.mdstep 2.target_layout—positionsHow bar position is parameterised.
one_lastTwo independent sigmoids, for position 1 and for position
group_size. Positions in between get identical supervision (negative on both), so the model is never asked “is this a 1 or a 3?”.positionsOne logit per bar position with a softmax over them, so the positions compete and 1-vs-3 becomes a single decision. Widens the head to
1 + group_sizechannels and switches the loss tobeat_position_loss().
Measured on ballroom val (binary, viterbi=2),
one_last→positions: confusion 0.185 → 0.130,f_one0.756 → 0.774,f_last0.730 → 0.769. Seeplans/04_beat_phase_generalization_and_data_prep.md§2.2 anddocs/beat_phase_improvement_review.mdsection 3.group_size—4Beats per group that the dataset’s position annotations count across: 4 for a bar-position (1–4) dataset such as ballroom, 8 for a phrase-position (1–8) dataset. It only changes what the “last” target/head means (position
== group_size); “one” is always position 1. Annotations counting a longer bar are folded onto1..group_sizeat load time (fold_positions()); meters that cannot fold evenly have their position supervision masked off instead.n_mels—128,hop_length—512,sigma_frames—1.5Front-end and target shape.
n_melsandhop_lengthare interpolated into the selectedmodel/*.yamlvia${n_mels}/${hop_length}, which is why they live at the config root rather than inside a section.sigma_framesis the standard deviation of the Gaussian smearing applied to each beat/position target, in frames.
Loss¶
pos_weight—autoPositive-class weight for the BCE heads: a scalar (shared across heads), a 3-element list (per head), or
auto.autoderives the weight per sample from the beat target, asalpha * (1 - mean(beat_y)) / mean(beat_y). A fixed value is only correct at one tempo — the true negative:positive ratio is 2.6 on jtd (193 BPM), 4.5 on ballroom (125) and 11.3 at classical’s 10th percentile (56 BPM), because the smeared target carries a constant ~3.75 frames of mass per beat while the beat period does not. Deriving it also keeps it correct undertime_stretch, which invalidates a hand-tuned value on every augmented clip.Measured negative:positive mass on ballroom (
binary_only, 150 tracks, 16 s clips), for a fixed value:head
maskbeatnote
one
20.2
4.7
last
20.0
4.6
beat
4.3
4.3
unconditioned either way
Note 4.7, not the naive 3.0 you get from “1 beat in 4 is a downbeat”: the weight is the smeared beat channel and the target is the smeared “one” channel, so the positive mass is a product of two Gaussians (sum ~2.66 per downbeat) while the total weight is a single one (sum ~3.76 per beat) — the positive side loses relatively more mass than the weight does. Hand-set values follow the convention of shading ~10% under the measured ratio; exact inverse-frequency weighting on a soft target tends to overshoot into false positives.
Warning
pos_weightis coupled to bothphase_conditioningandtarget_layout; change them together or not at all.target_layout: positionstakes a scalar orauto. A 3-element list is rejected at construction with an error naming both keys.target_layout: one_lasttakes the 3-element list[5, 4, 4].autois not supported here, and the failure is worse than an error: it dies inside torch asTypeError: new(): invalid data type 'str', which names neither config key.
So a
one_lastrun istarget_layout=one_last pos_weight=[5,4,4].pos_weight_alpha—1.11Scale factor for
pos_weight: auto.1.11reproduces the fixed value of 5 at ballroom’s median tempo, so switching toautois a pure cross-tempo recalibration — neutral on the corpus every previous measurement was taken on.1.0is exact inverse-frequency weighting.position_norm—per_itemHow the bar-position cross-entropy is averaged.
target_layout: positionsonly.globalOne weighted mean over the batch, i.e. a micro-average over beats. A clip’s influence is then proportional to its beat count: measured on
merge, jtd takes 73.7% of the position gradient against 63.8% of the tracks, and ballroom 18.0% against 24.1%. The gradient-share ratio between two corpora is exactly their tempo ratio.per_itemNormalize each clip by its own beat weight first, so every annotated clip carries
1/n_validregardless of tempo. That is a macro-average over tracks, matching the per-genre metric the project is judged on (tools/eval_beat.py --per-genre).
Note
position_normchanges the scale ofval/loss, whichModelCheckpointmonitors and splices into checkpoint filenames. Runs from before and after the switch are not loss-comparable.loss—bceWhich objective the
beatterm compares against the target.bceThe plain weighted cross-entropy — what every existing checkpoint was trained with, bit for bit.
tolerance_framesandignore_framesare not read.shift_tolerantThe max-pooled variant from Beat This! (ISMIR 2024) §3.3, which stops punishing a peak that is a frame or two off the annotation. See
musicality.losses.shift_tolerance.
Saved to the checkpoint’s hyperparameters, so a checkpoint records which objective trained it. Naming
shift_tolerantwithtolerance_frames: 0is refused rather than silently falling back — a config that names an objective and then disables it is a mistake, not a preference.tolerance_frames—3Half-width, in frames, of the timing error the loss forgives. Read by
loss: shift_tolerantonly, which is why it can sit in the config at its intended value whileloss: bceis selected — switching objectives is then a one-line change rather than three.3is ±69.7 ms at 43.07 fps, the same tolerancemir_evalscores at, so the loss forgives exactly what the metric forgives.It means two different things to the two heads, because the decoder reads them two different ways. The
beathead is scanned over time bypick_peaks(), so where its peak sits is the answer — tolerance forgives a peak a frame or two off, and in exchange the model is free to make that peak sharp instead of hedging with a wide bump. Thepositionhead is never scanned: the decoder rounds a beat time to a frame and reads that one column. Its answer is a label, not a time, so there is no peak to forgive; instead its supervision is widened across the window the lookup might land in — gate and target together.Warning
Coupled to
sigma_frames. Smearing the target and forgiving the prediction are two answers to the same problem, and they stack rather than compose:sigma_frames: 1.5already smears over ±4 frames, so the pair forgives ±162 ms against a ±70 ms metric. Beat This! rejects smearing outright, on the grounds that it mitigates slow convergence without fixing the blurred peaks it causes. Setsigma_frames: 0alongsideloss: shift_tolerant;build_beat_dataloaders()warns if you do not. The two are left independent so the pair can be swept.Sharp targets also move
pos_weight. Dropping the Gaussian cuts the positive mass ~3.75x, so the derivedautoratio climbs — which is whyAUTO_POS_WEIGHT_RANGE’s ceiling is 60 rather than 20:corpus
smeared
sharp
sharp,
ignore_frames: 4rwc_classical (10th pct, 56 BPM)
12.5
49.9
41.0
ballroom (median, 125 BPM)
5.1
22.1
13.2
jtd (median, 193 BPM)
2.9
13.9
5.0
ignore_frames—4Half-width of the band around each beat where the
beatterm’s negative half is switched off.nullderives it as2 * tolerance_frames, which is Beat This!’s rule; the config ships4instead, for the reason below. Read byloss: shift_tolerantonly, and it does not touch the position term, which has no negative class.The band exists because the two halves of the loss otherwise contradict each other: the positive half accepts a peak
rframes off the annotation while the negative half is simultaneously calling that same frame a mistake. A peak at+r, pooled over±r, reaches+2r, hence the default.Warning
That default is too wide at our frame rate. It ignores
4r + 1frames per beat, and jtd’s 193 BPM leaves only 13.4 frames between beats — sor = 3retains almost no negatives, on 63.8% ofmerge’s tracks. The negative term all but vanishes and a model that fires everywhere scores well. Frames surviving the band:corpus
ignore_frames: 6ignore_frames: 4rwc_classical (10th pct)
71.7%
80.4%
ballroom (median)
37.7%
56.9%
jtd (median)
3.8%
33.4%
Beat This! does not hit this: 50 fps gives more frames per beat at the same tempo, and their corpora skew slower. The pairing to start from here is
tolerance_frames: 3(to keep ±70 ms) withignore_frames: 4, which accepts a mild contradiction at the edge of the window — and that biases peaks towards its centre, which is no bad thing.balanced—trueBalances the logged
acc_one/acc_lastmetrics (the average of the true-positive and true-negative rates) instead of a pooled mean, which would be dominated by the true-negative rate since phase frames are a small minority. Reaches theone_lastlayout only — undertarget_layout: positionsnothing reads it. The beat head is scored bypeak_f_measure()(logged asf_beat), which peak-picks before matching and so has no true-negative term to balance.
Optimisation¶
lr—5e-4Adam learning rate.
BeatPhaseModule.configure_optimizerswraps it inReduceLROnPlateau(patience=5, factor=0.5)monitoringval/loss, stepped attrainer.check_val_every_n_epoch.weight_decay—1e-4L2 regularisation, passed to the same Adam.
batch_size—16Clips per batch, for both loaders.
trainer.*Passed straight to
lightning.Trainer:max_epochs,accelerator(cpu | gpu | auto),devices,log_every_n_steps,check_val_every_n_epoch.trainer.save_top_k(default 3) andtrainer.monitor(below) are read bybuild_checkpoint_callback()rather than by Lightning.trainer.monitor—val/f_beatWhich metric decides the epochs a run keeps. Any key the module logs; the default in code is
val/loss, and both beat configs override it becauseval/lossthere is dominated by the position head’s confidence rather than by decoded accuracy, so its best epoch is not reliably the best-decoding one.Min or max is not a separate key — it comes from the name via
metric_mode()(lossandmaeminimise, everything else maximises), since amodebeside it could disagree with the metric it names. The name also reaches the filename (beat-phase-epoch42-valfbeat0.8123.ckpt), which is howtools/leaderboard.pyknows which way to read asave_top_kgroup.
Data¶
data.input—mergeOne field serving two purposes, told apart by whether it contains a
/:A bare name (
ballroom,merge) is looked up under the canonical splits directory frommusicality.dataformats, aftersplit_name()applies the naming convention<name>[-binary].A path (
../musicality_db/splits/ballroom, or anywhere else on disk) is used directly as the split folder, bypassing the splits directory and the naming convention entirely — so a split can be trained on without being registered under a canonical name first.
Tracks whose annotation is flagged in the annotator (
warningorneeds_review) are dropped from both halves on read, so a split file’s line count is not the dataset size — seemusicality.splits.splitter.is_flagged().data.exclude—[]Corpora to subtract from whichever split
data.inputnames, by the name a split line carries before its/—data.exclude=[jtd]on the command line, orexclude: [jtd, gtzan]in the file. Empty means train on the whole split.This is a subtraction from a split, not a new split: every remaining track keeps the train/val side it was already drawn into, so a run excluding a corpus stays comparable with a run over the whole split, and no second split file has to exist. Use it to ask what one corpus is contributing (
data.exclude=[jtd]against an unmodified baseline) rather than editingsplits/.Both halves are dropped, not just training: a corpus the model was never shown has no business moving
val/lossor the event metrics. The exclusion is applied inresolve_split_refs(), which the validation dataloader andEventMetricsLoggerboth read their tracks through, so the two cannot disagree about what “val” means.A name the split doesn’t hold raises, listing the corpora it does hold — a typo must not silently train on everything. So does an exclusion that would leave no training tracks.
binary_only—trueDrop tracks whose beats-per-bar isn’t a multiple of 2 — e.g. ballroom’s waltz and Viennese waltz, which are in triple meter (1, 2, 3) rather than binary meter.
Warning
Must match whatever the split was built with (
--binary-onlyontools/create_splits.py), since it changes dataset length. It is folded into the split name, so flipping it reads a different split file. Without it, ~58% of what is called “val” is training data.data.sample_rate—22050,data.duration—16.0Audio sample rate, and clip length in seconds. The clip length interacts with the model’s receptive field in both directions. The trunk reaches
1 + (kernel_size - 1) * sum(dilations)frames — 1023 frames ≈ 23.8 s at the defaultn_layers: 9, so the receptive field now exceeds the 16 s crop rather than falling short of it as it did at eight layers (511 frames ≈ 11.9 s). That is the intended state: edge frames seeing some padding is normal. The hard limit is per layer, not per stack — a layer whose own dilation exceeds the crop has both off-centre taps in padding at every frame and degenerates into a 1x1 convolution. Atn_layers: 9the deepest dilation is 256 frames (5.9 s), comfortably inside a 16 s crop;n_layers: 11would put two layers past it and silently waste them. Seeplans/08_rethinking_the_approach.md§3.1.data.random_crop—trueTrain only: draw a random offset window per track on each access. The validation dataset is always built with
random_crop=False, taking a fixed window from the track’s middle so eval is reproducible — and, deliberately, so it avoids intros. That makes frame metrics measured on the easiest 16 seconds of each track, which is one of the reasons they sit above the event metrics; seedocs/frame_vs_event_metrics.md.data.num_workers—4DataLoader workers. Above 0 also enables
persistent_workers.train_subsample—nullFraction of the training split to use (e.g.
0.2), for quick smoke runs.augmentations.*enabledgates the whole block.time_stretch(min_rate/max_rate) resamples the clip and rescales the annotation times;gain(min_db/max_db) andnoise(std) each have their ownenabledflag. Time-stretch is safe on the target side and is whatpos_weight: autoexists to stay calibrated against.
Logging and outputs¶
checkpoint_dir—checkpoints_beat/Parent directory. Each run writes into its own subdirectory below it, named after
wandb.run_nameor — since W&B only generates a name after the logger connects, which is after callbacks are built — a timestamp. Each subdirectory holds that run’strainer.save_top_kbest checkpoints; without the per-run split, consecutive runs interleave andsave_top_kcannot prune another run’s files.event_metrics.*Event-level validation metrics, logged as
val_event/*: beats and bar positions decoded on full tracks and scored through the same path astools/eval_beat.py, so a number seen in W&B during training and the same number recomputed afterwards cannot disagree.enabledOff by omission as well as by
false.n_tracks—50Tracks scored per pass, drawn by a corpus-stratified fixed-seed sample;
nullscores the whole validation split. Cost is roughly one full-track model pass per track.every_n_epochs—5Scored on epoch 0, then every N — plus the final epoch, always.
Note
Postprocessing is not configured here — it comes from
evalbelow, resolved by the detected task, which is what keeps the training-time and eval-time decoders from drifting apart. Seemusicality.callbacks.event_metricsfor why these numbers differ from the frame metrics beside them.eval.*Composed, not written: the
defaults:list pulls inconfigs/eval_beat.yamlunder this key, so the decodeevent_metricsscores a run with is the onetools/eval_beat.pyre-scores it with afterwards. Only theeval.beat_onlyandeval.beat_phaseblocks are read, bypostprocess_knobs(); the run settings and sweep grid come along unused.Restating the knobs here instead would give the same numbers two homes, and a re-sweep would then silently only reach one of them. Override for one run on the command line as usual —
eval.beat_phase.decoder=greedy.A config that omits this composition leaves every knob unresolved, which surfaces as a failed decode on the first scoring epoch rather than as quietly different numbers.
training_report.enabled—trueWrites one
training_report.jsonper run beside that run’s checkpoints and uploads it to the W&B run’s Files tab. Holds final/best metrics, the per-epoch history, the per-track and per-corpus event scores, the resolved config, and the run’s identity — so a run can be handed over as a single attachment instead of a dashboard link. It decodes nothing; it reuses the event-metrics callback’s last scoring pass.wandb.*project,run_name(nulllets W&B generate one),tags.
# Beat-phase training (tools/train_beat.py).
# Every key is documented in docs/source/configuration.rst — put explanations
# there, not here. Trailing comments list legal values only.
defaults:
- model: tcn_frames
- eval_beat@eval
- _self_
task: beat_phase
# --- Head ---------------------------------------------------------------
phase_conditioning: beat # mask | beat
target_layout: positions # one_last | positions
group_size: 4 # 4 = bar position, 8 = phrase position
n_mels: 128
hop_length: 512
sigma_frames: 1.5
# --- Loss ---------------------------------------------------------------
# pos_weight is coupled to target_layout: `positions` takes a scalar or `auto`,
# `one_last` takes [5, 4, 4]. Mixing them fails obscurely — see the docs.
pos_weight: auto
pos_weight_alpha: 1.11
position_norm: per_item # global | per_item
# `shift_tolerant` replaces the sigma_frames smear above rather than adding to
# it, so switching it on wants sigma_frames: 0 as well. The two below are read
# by `loss: shift_tolerant` only.
loss: bce # bce | shift_tolerant
tolerance_frames: 3 # ±70 ms at 43.07 fps, the metric's own tolerance
ignore_frames: 4 # null = 2 * tolerance_frames; 4 is measured for our corpora
balanced: true
# --- Optimisation -------------------------------------------------------
lr: 5e-4
weight_decay: 1e-4
batch_size: 16
trainer:
max_epochs: 100
accelerator: auto # cpu | gpu | auto
devices: 1
log_every_n_steps: 10
check_val_every_n_epoch: 1
monitor: val/f_beat # any logged key; min/max is inferred from the name
# --- Data ---------------------------------------------------------------
# binary_only is folded into the split name, so it must match the split it is
# read with (`--binary-only` on tools/create_splits.py).
binary_only: true
train_subsample: null # fraction of the training split, for smoke runs
data:
input: merge # split name, or a path containing "/" to a train.txt/val.txt folder
exclude: [] # corpora to drop from that split, e.g. [jtd]
sample_rate: 22050
duration: 16.0 # seconds
num_workers: 4
random_crop: true # train only; val is always a fixed window from the middle
augmentations:
enabled: true
time_stretch:
enabled: true
min_rate: 0.85
max_rate: 1.15
gain:
enabled: true
min_db: -6.0
max_db: 6.0
noise:
enabled: false
std: 0.005
# --- Logging and outputs ------------------------------------------------
checkpoint_dir: checkpoints_beat/ # one subdirectory per run below this
event_metrics: # val_event/*, decoded on full tracks; postprocessing comes from eval_beat.yaml
enabled: true
n_tracks: 50 # null scores the whole validation split
every_n_epochs: 5
training_report:
enabled: true
wandb:
project: musicality-beat-phase
run_name: null
tags: []
Beat-only training (configs/train_beat_only.yaml)¶
The same scaffolding with the phase heads removed: one output channel, one BCE term. Keys behave as above except:
task—beat_onlySelects
BeatModuleat load time.pos_weight—6.0A plain scalar here — there is only one head. ~6:1 negative:positive on ballroom, measured directly from
BeatDataset; seedocs/beat_phase_pos_weight_notes.md.pos_weight_alpha—1.11As above, and read only when
pos_weightisauto. Present because this task’spos_weightis a plain scalar today butautoworks here too, and the beat-only head has the same tempo-dependent imbalance.loss—bce/tolerance_frames—3/ignore_frames—4As above, minus the position half — there is no position head here, so
shift_tolerantonly ever forgives the beat peak. The coupling tosigma_framesand theignore_frameswarning apply unchanged.balancedUnused, for the reason given above. Kept because it is stored in every existing checkpoint’s hyperparameters and passed back to the module on load.
binary_only—falseMeter does not affect a beat-only detector, so the filter is a data-selection lever rather than a requirement. It still has to match the split.
# Beat-only training (tools/train_beat_only.py).
# Every key is documented in docs/source/configuration.rst — put explanations
# there, not here. Trailing comments list legal values only.
defaults:
- model: tcn_frames_beat
- _self_
task: beat_only
# --- Head ---------------------------------------------------------------
n_mels: 128
hop_length: 512
sigma_frames: 1.5
# --- Loss ---------------------------------------------------------------
pos_weight: 6.0 # ~6:1 negative:positive on ballroom
pos_weight_alpha: 1.11 # read only when pos_weight is `auto`
# `shift_tolerant` replaces the sigma_frames smear above rather than adding to
# it, so switching it on wants sigma_frames: 0 as well. The two below are read
# by `loss: shift_tolerant` only.
loss: bce # bce | shift_tolerant
tolerance_frames: 3 # ±70 ms at 43.07 fps, the metric's own tolerance
ignore_frames: 4 # null = 2 * tolerance_frames; 4 is measured for our corpora
balanced: true # unused by this task; kept because it is stored in every checkpoint
# --- Optimisation -------------------------------------------------------
lr: 5e-4
weight_decay: 1e-4
batch_size: 2
trainer:
max_epochs: 100
accelerator: auto # cpu | gpu | auto
devices: 1
log_every_n_steps: 10
check_val_every_n_epoch: 1
monitor: val/f_beat # any logged key; min/max is inferred from the name
# --- Data ---------------------------------------------------------------
# binary_only is folded into the split name, so it must match the split it is
# read with (`--binary-only` on tools/create_splits.py).
binary_only: false
train_subsample: null # fraction of the training split, for smoke runs
data:
input: ballroom # split name, or a path containing "/" to a train.txt/val.txt folder
exclude: [] # corpora to drop from that split, e.g. [jtd]
sample_rate: 22050
duration: 16.0 # seconds
num_workers: 4
random_crop: true # train only; val is always a fixed window from the middle
augmentations:
enabled: true
time_stretch:
enabled: true
min_rate: 0.85
max_rate: 1.15
gain:
enabled: true
min_db: -6.0
max_db: 6.0
noise:
enabled: false
std: 0.005
# --- Logging and outputs ------------------------------------------------
checkpoint_dir: checkpoints_beat_only/ # one subdirectory per run below this
wandb:
project: musicality-beat-only
run_name: null
tags: []
Tempo training (configs/train_tempo.yaml)¶
loss—classificationabsoluteandrelativeregress BPM directly;classificationdiscretizes tempo into bins with a Gaussian target distribution and is the default. Onlyclassificationreads theclassification:block (bpm_min,bpm_max,n_bins,sigma).
Everything else (lr, batch_size, trainer.*, data.*,
augmentations.*, wandb.*) matches the beat configs.
# Tempo training (tools/train_tempo.py).
# Every key is documented in docs/source/configuration.rst — put explanations
# there, not here. Trailing comments list legal values only.
defaults:
- model: tcn
- _self_
# --- Loss ---------------------------------------------------------------
loss: classification # absolute | relative | classification
classification: # read by `loss: classification` only
bpm_min: 30
bpm_max: 286
n_bins: 256
sigma: 1.5
# --- Optimisation -------------------------------------------------------
lr: 5e-4
weight_decay: 0.0
batch_size: 32
n_mels: 128
hop_length: 512
trainer:
max_epochs: 100
accelerator: auto # cpu | gpu | auto
devices: 1
log_every_n_steps: 10
check_val_every_n_epoch: 1
# --- Data ---------------------------------------------------------------
train_subsample: null # fraction of the training split, for smoke runs
data:
input: ballroom # split name, or a path containing "/" to a train.txt/val.txt folder
exclude: [] # corpora to drop from that split, e.g. [jtd]
sample_rate: 22050
duration: 15.0 # seconds
num_workers: 4
augmentations:
enabled: true
time_stretch:
enabled: true
min_rate: 0.75
max_rate: 1.25
gain:
enabled: true
min_db: -6.0
max_db: 6.0
noise:
enabled: false
std: 0.005
# --- Logging and outputs ------------------------------------------------
checkpoint_dir: checkpoints/ # one subdirectory per run below this
wandb:
project: musicality
run_name: null
tags: []
Model backbones (configs/model/)¶
Selected through each training config’s defaults: list, or overridden on the
command line (model=tcn). All three are the same dilated TCN trunk
(TCNTempoNet) at different output shapes.
tcn.yamlClip-level tempo regression. Used by
train_tempo.yaml.tcn_frames.yamlFrame-level, 3 outputs. Used by
train_phase_beat.yaml.frame_levelandn_outputsare forced toTrue/3byBeatPhaseModuleregardless of what is set here; they are listed for documentation only.tcn_frames_beat.yamlFrame-level, 1 output. Used by
train_beat_only.yaml.frame_levelandn_outputsare likewise forced, toTrue/1, byBeatModule.channels—32/n_layers—9Trunk width and depth. Both frame-level backbones moved here from
256/8on 2026-09-17;tcn.yaml(tempo) did not — see below.Width.
channelsis quadratic in parameters, and 256 was buying memorisation rather than accuracy. Three measurements agree:plans/07_beat_phase_v6_and_next_moves.md§1.2 shows the train/val gap onpos_accwidening 0.026 → 0.194 while the gap onf_beatbarely moves (0.024 → 0.038); a training run reached 0.007 half-cycle confusion on train against 0.130 on val; and at 256 channels the model held 1.61 M parameters against 1.28 M labelled frames per epoch — more parameters than data points. At32the same backbone is 40,773 parameters, a 40x cut, still above the tens-of-thousands the reference convolutional beat trackers use.Depth.
n_layersis linear in parameters and doubles the receptive field each time, so eight layers reached 11.9 s — less than the 16 s crop being trained on. Nine reaches 23.8 s and every layer stays fully live (seedata.duration). Nine is the ceiling for a 16 s crop, not a free parameter: ten is half-wasted and eleven is two dead layers. Going deeper needs a repeating dilation schedule rather than a longer crop —plans/08_rethinking_the_approach.md§3.1 specifies it; it is not implemented.Note
Existing checkpoints are unaffected.
musicality.inference.load_modulereads a checkpoint’s own savedhyper_parametersand never consults these files, socheckpoint_v6.ckptand friends still load and evaluate at 256x8. Only new training runs change shape.Warning
A run at these defaults changes three things at once against v6 — width, depth, and the
conv2d_stembelow — so its result attributes to none of them individually. To separate them, override one at a time:model.channels=256 model.n_layers=8isolates the stem,model.conv2d_stem=falseisolates the resize, andmodel.channels=64/model.channels=16walk the width ladder.tcn.yamlis deliberately left at256/8The evidence above is entirely beat-phase: a frame-level position head, its own overfitting signature, and a receptive-field argument that assumes per-frame outputs. Tempo regression pools globally over time, so neither the depth argument nor the measured train/val gap transfers. Resizing it would be extrapolation from another task’s data.
use_self_attention—falseAdds a self-attention head over the phase channels only; the beat channel always reads straight off the trunk.
n_attn_layersandn_attn_headssize it.Warning
Currently off on purpose, not merely unexercised.
plans/05_beat_phase_overfitting.md§2 rules it out: +0.79M parameters (+45%) for ≤0.023 macro confusion, itself confounded with the position-folding fix.docs/beat_phase_improvement_review.md§4 lists four unresolved defects in the block — absolute sinusoidal positional encoding is actively harmful underrandom_crop, the softmax flattens at full-track length, post-LN sits on an unnormalized input, and there is no dropout inside the block. It also needs clips longer than the trunk’s own receptive field (~23.8 s at the defaultn_layers: 9) to have any long-range context to draw on — which a 16 s crop no longer is.fixed_norm—trueNormalise the log-mel with frozen per-band statistics instead of statistics taken over the input tensor.
Off, the model takes one mean and one std over both axes of whatever it is handed. Training passes a 16 s crop;
run_inferencepasses a whole track. The same bars therefore arrive at a different scale depending on what surrounds them. Measured over 100 tracks, the shift is 0.04 sigma on gtzan and 0.33 (worst 1.61) on rwc_classical — the corpus where every tracker collapses.plans/08_rethinking_the_approach.md§2.1 item 4 has the full table.On, one mean and std per mel band are measured once at the start of training by
fit_input_stats()and stored as buffers, so they travel in the checkpoint and inference normalises exactly as training did. The window dependence is then gone by construction — verified on real audio, where a 16 s crop’s network input is bit-identical to the same window read out of a 60 s pass.Two caveats. The model no longer adapts to a recording’s overall level, so more rests on gain augmentation (±6 dB). And this fixes only the normalisation term: convolution padding is a separate train/inference difference, and at
n_layers: 9the receptive field exceeds the crop, so a whole-track pass is still not equivalent to a crop end to end.Default-off in the code so checkpoints predating it reconstruct their own behaviour; on in both frame-level configs.
tcn.yaml(tempo) keeps it off, on the same reasoning as the trunk size.conv2d_stem—trueRuns a
Conv2dStembetween the log-mel and the dilated trunk instead of projecting the raw bands straight through a 1x1 convolution.What it changes. With the stem off, the model’s first operation is
Conv1d(n_mels, channels, kernel_size=1)— at every frame, a fixed linear mixture of all mel bands — after which the band axis is gone and every remaining layer convolves over time only. Nothing in the network ever sees a time-frequency neighbourhood. With it on, threeConv2dblocks with frequency-only max-pooling run first, and the surviving frequency bins are folded into channels for the trunk.Why. An onset is a local, relative event: energy rising, within a limited band, over ~20 ms. A 1x1 mixer is frequency-absolute — it learns one weight per band applied identically at every frame, so a kick drum and a walking bass note are the same event shape it has to detect twice, in separate output channels. Worse, mixing before differencing lets onsets cancel: with the bands summed first, one band rising while another falls produces a flat mixture, which is exactly a harmonic change with no percussive attack — the dominant downbeat cue wherever no drum marks the bar.
plans/08_rethinking_the_approach.md§2.1 is the long version, and §1.3 there is the measurement that motivates it: Beat This! reaches 0.935f_beat/ 0.818position_accon gtzan with no decoder at all, so its advantage lives in the front end and trunk.Cost. At the current
channels: 32, 32,805 parameters to 40,773 — the stem itself is 4.9 k and the rest isinput_projwidening from 128 to 224 inputs. It was measured at the oldchannels: 256as 1.613 M to 1.643 M; the absolute cost is the same, which is why it composes with the resize above rather than competing with it.On by default since 2026-09-17, for the frame-level backbones only.
tcn.yaml(tempo) keeps it off, on the same reasoning as the trunk size. Turning it off is still a supported comparison (model.conv2d_stem=false), and checkpoints trained before the stem existed load into identical parameter shapes either way, since inference reads their saved hyperparameters rather than this file.stem_channels—16/stem_layers—3/stem_freq_pool—3Read only when
conv2d_stemis on.stem_layersConv2dblocks, with frequency pooled bystem_freq_poolafter every block except the last — so the default schedule takes 128 bands to 42 to 14, and 14 × 16 = 224 channels reach the trunk.Two invariants worth not breaking. Time is never pooled: the frame rate is the output resolution, and ±1 frame of quantisation is already 23.2 ms against a 70 ms tolerance. And the frequency axis is folded into channels rather than pooled away, because which band a feature fired in is information the bar-position head has no other route to. A schedule that would leave fewer than one frequency bin raises at construction rather than failing on the first batch.
Evaluation (configs/eval_beat.yaml)¶
Defaults for tools/eval_beat.py, every key overridable via the matching
--flag (e.g. --beat-threshold 0.4). Also read by the annotator, and
composed into train_phase_beat.yaml under eval (see below), so these
are the project-wide postprocessing defaults rather than one CLI’s.
Nothing under musicality/ opens this file.
BeatEvaluator takes every setting defined here
as a required keyword argument — the postprocess block, plus
sample_rate, hop_length, tolerance and device — and defines no
fallback of its own. So these values live here and only here: a re-swept
number cannot be quietly overridden by a stale copy in the library, importing
the library does not depend on this file being on disk, and a caller that
forgets one is told at construction rather than scored against a value it
never chose.
split, val_split and binary_only are the exception: they default
to None for not supplied, because they are read only when a split has to
be resolved, which
from_module() skips. That is still
not a default value — load() names the missing ones rather than
inventing them.
Top level¶
dataset, split (train | val | all), val_split, binary_only,
sample_rate, hop_length, tolerance, device. sample_rate and
hop_length must match the checkpoint’s training config; val_split must
match how the split was created; tolerance is the F-measure matching window
in seconds.
dataset and binary_only together name the split that gets evaluated —
musicality.splits.splitter.split_name() folds the second into the
directory name, so merge + binary_only: true reads merge-binary. They default to what configs/train_phase_beat.yaml
trains on, so evaluating a checkpoint needs no flags to land on the split it was
held out against. --no-binary-only (or binary_only: false) evaluates the
meter-mixed split instead, which only makes sense for a checkpoint trained on
it: the beat-phase one/last targets assume a binary meter, so a waltz
scored against them is being asked the wrong question.
beat_only:¶
Postprocessing for a beat-only checkpoint, selected automatically by
musicality.inference.detect_task(). Tuned via --sweep against a real
checkpoint, mean beat F-measure 0.735 → 0.896.
beat_thresholdpick_peaks: minimum probability to be considered a peak at all.min_distance_framespick_peaks: minimum frame gap enforced between returned peaks.gate_tolerancegate_periodicity: relative slack around an integer multiple of the current beat period.
beat_phase:¶
The same three beat knobs, plus group_size and the bar-position stage.
Warning
The four beat-detection values in this block are unverified. They were
produced by the old tools/sweep_beat_postprocess.py, which hardcoded the
probability channels and never passed the decoder, switch penalty or
position probabilities — so it was sweeping the greedy decoder against a
two-sigmoid one_last head, neither of which train_phase_beat.yaml trains
any more. Re-sweep before trusting them:
uv run python tools/eval_beat.py --checkpoint <ckpt> \
--dataset merge --split val --binary-only --sweep
Reported at the time: mean beat F-measure 0.916 on
checkpoints_beat/loss=1.6565.ckpt, binary_only=True, ballroom val.
decoder—globalBar-position stage.
globalrunslabel_bar_position_global(), a whole-track maximum-likelihood decode over the soft position probabilities;greedyruns the older count-forwardlabel_bar_position(). Measured on the same checkpoint with no retraining (tools/eval_beat.py --decoders, switch penalty tuned on train and reported on val):decoder
f_onef_lastconfusion
greedy (anchor=0.8)
0.697
0.692
0.253
global (switch=2.0)
0.756
0.730
0.185
See
docs/beat_phase_improvement_review.mdfor why the greedy decoder loses.switch_penalty—2.0Log-cost of a mid-track phase resync in the global decoder.
nullforbids resyncs entirely (an exact single-offset decode); lower values resync more eagerly. The optimum is interior — on val, 0.25 scores 0.274 confusion and 40.0 scores 0.219, both worse than 2.0’s 0.185. Seedocs/switch_penalty_explained.md.anchor_threshold—0.8Minimum probability for a beat to be trusted as a confident “1”/”last” anchor. Read by the
greedydecoder only.
sweep:¶
Grid searched by tools/eval_beat.py --sweep, overridable per run via
--sweep-beat-thresholds / --sweep-min-distances /
--sweep-gate-tolerances / --sweep-anchor-thresholds /
--switch-penalties / --top.
The sweep runs in two stages rather than over the full cartesian product: the
beat grid is scored first, and the winning beat knobs are then held fixed while
the bar-position knob is swept on top of them. Bar-position decoding consumes
whatever beats the peak-picker found, so a beat setting that loses on f_beat
cannot win on position_acc — which makes the joint product (60 × 7 = 420
combinations) mostly wasted work for the same answer as 60 + 7.
anchor_thresholds is swept only when the resolved decoder is greedy, and
switch_penalties only when it is global; the latter always scores the
no-resync decode (null) alongside, and doubles as the variant list for
--decoders.
# Defaults for tools/eval_beat.py, and the project-wide postprocessing defaults
# (musicality.evaluation.DEFAULTS, tools/annotator/inference.py).
# Every key is documented in docs/source/configuration.rst — put explanations
# there, not here. Every key is overridable via the matching --flag.
# --- Run ----------------------------------------------------------------
dataset: merge
split: val # train | val | all
val_split: 0.2 # must match how the split was created
binary_only: true # must match how the split was created
sample_rate: 22050 # must match the checkpoint's training config
hop_length: 512 # must match the checkpoint's training config
tolerance: 0.07 # F-measure matching window, seconds
device: cpu
# --- Postprocessing, per task -------------------------------------------
# Selected automatically by musicality.inference.detect_task.
beat_only:
# Tuned via --sweep: mean beat F-measure 0.735 -> 0.896.
beat_threshold: 0.8
min_distance_frames: 4
gate_tolerance: 0.1
beat_phase:
# !! The four beat-detection knobs below are UNVERIFIED — they were swept
# against a head and a decoder this config no longer trains. Re-sweep before
# trusting them; see docs/source/configuration.rst.
beat_threshold: 0.5
min_distance_frames: 4
gate_tolerance: 0.1
group_size: 4 # 4 = bar position, 8 = phrase position
decoder: global # global | greedy
switch_penalty: 2.0 # global only; null forbids mid-track resyncs
anchor_threshold: 0.8 # greedy only
# --- Sweep grid ---------------------------------------------------------
# Two-stage: beat knobs first, then the bar-position knob on top of the winner.
sweep:
top: 10
split: train # tools/leaderboard.py: which split the knobs are tuned on
tracks: 50 # tools/leaderboard.py: stratified subsample of that split
beat_thresholds: [0.2, 0.4, 0.5, 0.6, 0.8]
min_distance_frames: [1, 2, 4]
gate_tolerances: [0.1, 0.15, 0.2, 0.3]
anchor_thresholds: [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] # decoder: greedy only
switch_penalties: [0.25, 1.0, 2.0, 5.0, 20.0] # decoder: global only; also the --decoders list
Dataset download (configs/download.yaml)¶
data_homeWhere
tools/download_dataset.pywrites. Defaults to the sibling../musicality_dbgit+dvc repo.datasetsNames passed to
mirdata.
gtzan_genre is deliberately absent: mirdata’s loader has a dead audio link
(opihi.cs.uvic.ca). Use data_home/dl_gtzan.py (the HuggingFace
marsyas/gtzan mirror) to populate data_home/gtzan/, drop beat
annotations under gtzan/annotations/beats/, then run
tools/migrate_gtzan.py.
# Datasets for tools/download_dataset.py, fetched via mirdata.
# Documented in docs/source/configuration.rst.
data_home: ../musicality_db
datasets:
- ballroom
- rwc_classical
- rwc_jazz
- rwc_popular
- jtd
# gtzan is deliberately absent — mirdata's audio link is dead. See the docs for
# the HuggingFace mirror route.