ML-Based UL MCS Link Adaptation
ML-Based UL MCS Link Adaptation
Uplink link adaptation in a 5G scheduler chooses the modulation-and-coding scheme (MCS) for each grant. The stock approach is Outer-Loop Link Adaptation (OLLA): map the measured SINR to an MCS and nudge an offset up or down based on CRC outcomes. This release layers a learned model on top of OLLA that predicts decodability directly, so the scheduler can pick a more aggressive MCS when it is confident the block will still decode, raising effective uplink throughput while holding the block-error rate (BLER) under a target.
ml_mcs block.Source. This feature is part of the OCUDU RAN: github.com/TOSSI-Foundation/OCUDU-RAN (the ml_mcs release).
Design Principles
- No ML runtime in the RAN binary. The trained model is exported as a flat C++ array traversal compiled into the DU (or loaded at runtime from a JSON file). No Python interpreter, ONNX runtime, or TensorFlow is linked.
- Safe by default. Every ML parameter is
enabled: falseby default. Without explicit configuration the DU behaves identically to the stock release. - OLLA is the fallback at every decision point. If the model is not configured, is reverted, or finds no MCS meeting the BLER target, it returns the OLLA MCS.
- Single config surface. All ML parameters live in the DU YAML
ml_mcsblock. The C++ scheduler and the Python sidecar read the same file; there are no environment variables. - Self-adapting. An optional sidecar retrains the model on locally collected data on a fixed interval, validates the candidate, and promotes it to the running DU via an atomic file write. The DU hot-swaps the model without a restart.
The Decision Model
The model is a binary classifier that predicts P(crc_success | channel state, candidate MCS), a calibrated probability rather than a hard class. That calibration is what makes the BLER target a tunable knob.
Features and label
Five input features, in fixed order:
| # | Feature | Type | Meaning |
|---|---|---|---|
| 0 | mcs | uint8 | MCS index being evaluated (swept by the predictor) |
| 1 | mcs_table | uint8 | PUSCH MCS table (qam64=0, qam256=1, qam64LowSe=2) |
| 2 | wideband_cqi | uint8 | Latest wideband CQI reported by the UE |
| 3 | pusch_avg_sinr_db | float | EWMA of PUSCH SINR at the gNB receiver (dB) |
| 4 | ul_snr_offset_db | float | Current UL OLLA SNR offset (dB) |
The label is crc_success (1 if the transport-block CRC passed, else 0). Including the OLLA offset as a feature prevents the model from double-counting channel quality the scheduler has already adjusted for. Training uses new-transmission rows only (nof_retxs == 0): the channel conditions at the original grant time are what the label should reflect.
Algorithm and output
The model is a scikit-learn GradientBoostingClassifier. Default hyperparameters (all CLI-overridable):
| Hyperparameter | Default |
|---|---|
n_estimators | 200 |
max_depth | 4 |
learning_rate | 0.08 |
subsample | 0.8 |
At inference the C++ runtime reproduces scikit-learn's predict_proba exactly as a logistic sigmoid over the sum of tree leaf values:
score = init_logodds
for each tree t:
score += learning_rate * leaf_value(t, features)
P(crc_success) = 1 / (1 + exp(-score))
The Decision Rule
In calculate_ul_mcs(), after OLLA computes a baseline MCS, the predictor sweeps candidate MCS from high to low and returns the highest one whose predicted success meets the target:
for mm = mcs_hi down to mcs_lo:
feat = [mm, mcs_table, wideband_cqi, pusch_avg_sinr_db, ul_snr_offset_db]
if predict_proba(feat) >= (1.0 - bler_target):
return mm # highest MCS meeting the target
return olla_mcs # fallback: no MCS qualified
Because the sweep starts at the highest allowed MCS, the model always selects the most aggressive MCS that still clears the success threshold, maximising throughput subject to the BLER constraint. The model only ever selects its greedy best MCS; it never deliberately transmits a suboptimal one, so it cannot harm the link through exploration.
In-RAN Runtime
The predictor singleton
mcs_ml::predictor (in lib/scheduler/support/mcs_ml_predictor.h) is a process-wide singleton that owns the active model, the file-based hot-swap, and the auto-revert. At UE-controller init it loads the compiled seed model, then, if a runtime model path is configured, loads that file and records its modification time.
The active model is a shared_ptr<const model> read with std::atomic_load and written with std::atomic_store. This gives the scheduler thread lock-free reads while the hot-swap path publishes a new immutable model safely. A minimal flat JSON parser reads the runtime .model file with no third-party library, validating every array dimension before accepting it.
Hot-swap and inference hook
Hot-swap is mtime-gated and throttled: maybe_reload() runs on every select_mcs() call but only polls the filesystem every 2000 calls. When the model file's mtime changes, the new model is parsed and atomically swapped in, off the scheduling hot path. The inference hook itself is a small gated override at the end of calculate_ul_mcs(): when ML is disabled or falls back, the returned MCS is unchanged from the OLLA path.
Dataset logger
At CRC-indication time (ue_cell::handle_crc_pdu()) a tap writes one CSV row per decoded PUSCH. Each row joins the grant parameters and channel state (the features) atomically with the CRC outcome (the label), plus the model's predicted success for the MCS that actually flew. The result is a complete, self-labeled training example. When logging is disabled the tap is a single null-pointer test, branch-predicted away; when enabled, writes are mutex-guarded and flushed every 64 rows so partial datasets survive a process exit.
The CSV carries 44 columns. Training uses only the 5 features plus nof_retxs and crc_success; the remaining columns (MIMO/CSI fields, OLLA state, HARQ occupancy, timing decomposition, slicing context) are captured for future use at no extra RAN cost beyond the single write.
Python ML Platform
| Tool | Role |
|---|---|
ml/ml_config.py | Shared reader for the ml_mcs YAML block, so the Python tools see exactly the parameters the C++ scheduler uses. |
ml/training/train_export_mcs.py | Offline trainer: load CSVs, filter to newTx, time-split with dedup, fit the GBT, report held-out AUC/Brier, refit on all rows, export .inc (compiled seed) and .model (runtime). |
ml/serving/online_trainer.py | Live sidecar: retrain on the interval, run the promotion gate, atomically promote, and trip the auto-revert kill-switch when needed. |
ml/analysis/analyse.py | OLLA-vs-ML comparison: aggregate BLER and mean MCS, SINR-matched delivered bytes per transmission, and per-MCS coverage. |
Training is leakage-safe: the data is time-split (earliest rows train, latest validate), the training set is deduplicated by exact feature+label row, and any test row whose feature vector appears in the deduplicated training set is excluded. Metrics are reported on the held-out set; the deployed model is then refit on all rows. Python dependencies are numpy, scikit-learn, and pyyaml.
Online Training and Safety
The sidecar retrains on the full accumulated dataset every interval_min, validates the candidate against the live model on a recent window, and only promotes it if all three conditions hold:
| Gate condition | Check |
|---|---|
| AUC does not regress | auc_candidate >= auc_incumbent - 0.01 |
| Calibration does not regress | brier_candidate <= brier_incumbent + 0.01 |
| Predicted BLER stays safe | 1 - mean(P_success) <= floor_bler |
A promoted candidate is written to model_path + ".tmp" and renamed with os.replace() (atomic on POSIX); the previous model is kept as .lastgood. The running DU picks up the new file on its next reload poll.
Auto-revert kill-switch. If the live model fails the gate for two consecutive cycles and the observed BLER on recent data exceeds floor_bler, the trainer touches the revert_flag file. The predictor detects it (polled off the hot path) and returns the OLLA MCS on every slot until the flag is cleared. The flag can also be created or deleted by hand at any time as an operational kill-switch, with no config change or DU restart.
Configuration Reference
All parameters live in the DU YAML ml_mcs block. C++ defaults match the YAML defaults; everything is disabled by default.
ml_mcs:
inference:
enabled: false # enable ML UL MCS selection (else OLLA)
bler_target: 0.10 # P(failure) threshold for the decision rule
model_path: "" # runtime .model; empty = compiled seed
dataset_logging:
enabled: false # write one CSV row per decoded PUSCH
output_dir: ml/datasets
scenario: default # tag written into each row
online_training:
enabled: false # run the Python sidecar retraining loop
interval_min: 15 # retrain interval (minutes)
min_rows: 20000 # newTx rows required before first retrain
val_window: 10000 # rows used for the promotion gate
floor_bler: 0.30 # BLER floor for the auto-revert trigger
revert_flag: "" # auto-revert flag path; empty = no revert
Deployment and Usage
1. Collect a dataset
Enable logging in the DU YAML and run the DU with live UEs. Timestamped CSVs accumulate in output_dir. A useful first dataset is around 20k new-transmission rows.
ml_mcs:
dataset_logging:
enabled: true
output_dir: ml/datasets
scenario: site_a
2. Train a model (optional)
python3 ml/training/train_export_mcs.py ml/datasets/ul_mcs_dataset_*.csv \
--out lib/scheduler/support/mcs_ml_model.inc \
--model-out ml/models/site_a.model
This prints the held-out AUC/Brier and writes both the compiled seed (.inc) and the runtime model (.model). Rebuild the DU only if you changed the compiled .inc; the .model is loaded at runtime with no rebuild.
Using an external dataset. Any dataset with the seven required columns (mcs, mcs_table, wideband_cqi, pusch_avg_sinr_db, ul_snr_offset_db, nof_retxs, crc_success) works, from another OCUDU deployment, a lab capture, a simulator, or a public RAN dataset. A model trained on a different environment is a warm-start; validate it with analyse.py and let online training adapt it to the local site. You can also skip training and run the shipped seed directly.
3. Enable inference
ml_mcs:
inference:
enabled: true
bler_target: 0.10
model_path: ml/models/site_a.model
Restart the DU. The predictor loads the model at startup and emits one greppable ML_LA: log line per decision showing the model vs OLLA pick.
4. Enable online learning
ml_mcs:
online_training:
enabled: true
interval_min: 15
min_rows: 20000
revert_flag: /tmp/ocudu_ml_revert
Start the sidecar alongside the running DU, pointed at the same YAML:
python3 ml/serving/online_trainer.py --config configs/<du_config>.yaml
It retrains every interval_min, validates against the floor, atomically writes the .model, and the DU hot-swaps it within 2000 UL scheduling decisions. The --once flag runs a single cycle for testing.
5. Compare against OLLA
python3 ml/analysis/analyse.py --data-dir ml/datasets
This reports OLLA-vs-ML effective throughput (delivered bytes per transmission), SINR-matched, plus per-MCS coverage and BLER. Effective throughput, not raw BLER, is the metric that decides whether ML helps: the model trades BLER for higher MCS where it predicts success.
Observability
With inference enabled, the predictor logs one line per decision to the SCHED logger:
ML_LA: olla_mcs=14 ml_mcs=17 delta=+3 p_succ=0.9123 bler_target=0.10 sinr_db=18.4 cqi=9 src=ml
ML_LA: olla_mcs=14 ml_mcs=14 delta=0 p_succ=0.7841 bler_target=0.10 sinr_db=10.1 cqi=6 src=olla_fallback
src=ml means the ML pick was returned; src=olla_fallback means no MCS met the threshold. Hot-swaps log a hot-swapped model line, and the online trainer prints timestamped candidate trained / gate: PASS / PROMOTED events.
Shipped Seed Model
The seed model shipped with this release was trained on 900k+ new-transmission rows of real over-the-air data from a live deployment, reaching a held-out AUC of 0.9732 and a Brier score of 0.0608. It is a warm-start: usable out of the box, then adapted to a new deployment by online training or by retraining on local data. Two artifacts are emitted: a compiled-in .inc seed (the fallback when no runtime model is configured) and a JSON .model loaded at startup and hot-swapped without a rebuild.