How to use and write a learning-based rule¶
HandoverLab gives a rule the outcome of every handover attempt it made, so a rule can learn while
it runs. The built-in a3_bandit (since 1.0.0rc2) is a small example: Event A3 whose offset every
cell learns with an epsilon-greedy multi-armed bandit. It needs nothing beyond NumPy and is meant
as a starting point for your own learning rules, not as a tuned method.
1. How a3_bandit decides and learns¶
- Trigger. Event A3 (TS 38.331 clause 5.5.4.4) with a fixed
ttt_s; the offset of a user is the arm its serving cell currently plays, one ofarms_db. - Reward. Every outcome of a handover from a cell is a reward for that cell: useful
+1, unnecessary-w_unnecessary, too early or too late-w_failure; a link failure without a handover counts as late (-w_failure). These are the classes of mobility robustness optimisation (TS 38.300 clause 15.5.2) plus unnecessary handovers. The labels are what the network can observe (handoverlab.rules.USEFUL,UNNECESSARY,EARLY,LATE), not the true outcomes. - Learning. A cell plays one arm for
epochoutcomes, then updates the mean reward of that arm and chooses the next one: with probabilityepsilonat random, otherwise the first arm it has not played yet, otherwise the best. Random draws come fromctx.rng, so runs are repeatable.
With a single arm it is a3 with that offset; the test suite checks that both give the same event
log.
2. Run it next to fixed A3¶
schema: 1
scenario: {base: smoke, mobility: {users: 30}, run: {duration_s: 120.0, warmup_s: 60.0}}
seeds: [1, 2, 3]
rules:
- {rule: a3, off_db: 3.0, ttt_s: 0.16}
- {rule: a3_bandit, arms_db: [1.0, 2.0, 3.0, 4.0, 5.0], ttt_s: 0.16, epsilon: 0.1, epoch: 5}
output: results/bandit.csv
handoverlab run bandit.yaml
A learning rule starts knowing nothing in every run. Give it a long warm-up
(run.warmup_s): the metrics count only what happens after it, so the comparison is between the
learned behaviour and the fixed rule. Short runs mostly measure exploration.
simulate gives every run its own copy of a rule object, so nothing learned leaks between runs or
seeds. The event log shows what the cells chose: for baseline-family rules the column o_exec is the
A3 offset an attempt used.
import handoverlab as hl
sc = hl.load_config("bandit.yaml").scenario
(res,) = hl.simulate(sc, [{"rule": "a3_bandit", "epoch": 5}], seeds=[1], log_events=True)
for e in res.events[-10:]:
print(e["t_attempt"], e["src"], e["o_exec"], e["outcome"])
3. Things to vary¶
w_unnecessaryagainstw_failuresets the trade-off the rule learns: a largerw_failurepushes cells towards smaller offsets (earlier handovers).epochtrades noise for speed: few outcomes per epoch give noisy estimates; many make learning slow in cells with little traffic.epsilonkeeps some exploration going after all arms were played.
In a study, give its parameters a grid like any other rule; the equal-budget tuning and the paired tests treat it the same way (see the tuning tutorial).
3a. Does the bandit find the best offset? (checked in 1.11.0)¶
handoverlab verify --only learning (about 20 minutes) compares a3_bandit with an exhaustive
search over its own arms: every arm is run as a fixed A3 offset (a one-arm bandit, which gives the
same event log as a3), and the bandit's own reward per outcome is measured for each. The set-up
and three criteria were written down before the first run: the smoke card for 900 s, seeds 1-3,
the rule's default parameters.
| 1 dB | 2 dB | 3 dB | 4 dB | 5 dB | |
|---|---|---|---|---|---|
| reward per outcome as a fixed offset | 0.286 | 0.385 | 0.471 | 0.546 | 0.618 |
| Criterion | Result |
|---|---|
| B1: second half of the epochs on the best arm or a neighbour, at least 0.6 | failed: 0.57 (chance level 0.4) |
| B2: reward over the whole run at least the median fixed arm (0.471) | passed: 0.503 |
| B3: reward in the second half at least best - 0.5 (best - worst) (0.452) | passed: 0.543 |
The bandit learns in the right direction, more slowly than the criterion asked for: in 900 s each of the 26
cells completes only about 10 epochs of 10 outcomes, and exploration (10 %) and the first round over
all arms take a part of them. The best fixed offset is the largest arm and the reward still rises
there, so on this card the default arms end too early; choose arms_db that reach beyond the
optimum you expect. The rule's parameters were not changed after the run.
3b. A deep Q-network: dqn (since 1.11.0, optional PyTorch)¶
pip install handoverlab[torch] installs PyTorch; without it the rule is listed by
handoverlab rules with the note "needs PyTorch" and refuses to be built, and the rest of
HandoverLab works as before. The rule is Event A3 whose offset a Q-network picks per user:
- state from what the network observes: serving L3 RSRP, the three best neighbour differences and the reported speed;
- action the A3 offset (one of
offsets_db) used until the user's next handover or failure; - reward as for
a3_bandit(useful +1, unnecessary-w_unnecessary, early, late or a link failure-w_failure), arriving when the outcome is known; - learning Q-learning with a replay buffer and a target network, epsilon-greedy while training.
Train on seeds that never appear among the test seeds, save the weights, then test:
import handoverlab as hl
from handoverlab.rules.dqn import train
sc = hl.builtin_scenario("smoke").with_overrides({"run.duration_s": 120.0})
train(sc, seeds=[101, 102, 103], path="dqn_smoke.pt") # learns, carries the network over the seeds
res = hl.simulate(sc, [{"rule": "a3", "off_db": 3.0, "ttt_s": 0.16},
{"rule": "dqn", "mode": "test", "weights": "dqn_smoke.pt"}], seeds=[1, 2, 3])
CPU only, deterministic algorithms, one thread per pass and seeds from ctx.rng: two runs on one
machine and PyTorch version give identical event logs. Other hardware or versions may give other
numbers, so there is no golden fingerprint for this rule; the test suite checks the contract and the
repeatability instead. The rule is an example of how to plug a neural network into the rule
interface, not a tuned method: in a check run (two training seeds of 120 s, two test seeds) it had
fewer unnecessary handovers than A3 with 3 dB and 160 ms (1.70 and 1.17 against 2.37 and 1.57 per
user-minute) and on one seed more failures (0.05 against 0).
4. Write your own learning rule¶
A learning rule sets learns = True and implements on_outcome (and, if it wants, on_link_failure):
import numpy as np
import handoverlab as hl
from handoverlab.rules import EARLY, LATE, UNNECESSARY
@hl.register_rule("a3_step")
class StepA3(hl.BaselineRule):
"""Every cell raises its offset after an unnecessary handover and lowers it after a failure."""
learns = True
def __init__(self, step_db=0.5, ttt_s=0.16, name="A3-step"):
super().__init__(name)
self.step_db, self.ttt_s = step_db, ttt_s
def params(self):
return dict(step_db=self.step_db, ttt_s=self.ttt_s)
def reset(self, ctx):
super().reset(ctx)
self.off = np.full(ctx.n_sites, 3.0) # learned state lives in the rule, per run
def a3_offsets(self, obs):
return np.repeat(self.off[obs.serving][:, None], self.ctx.n_sites, axis=1)
def time_to_trigger(self, obs):
return self.ttt_s
def on_outcome(self, rec):
delta = -self.step_db if rec.label in (EARLY, LATE) else (self.step_db if rec.label == UNNECESSARY else 0.0)
self.off[rec.source] = np.clip(self.off[rec.source] + delta, 0.0, 8.0)
def on_link_failure(self, rec):
self.off[rec.source] = max(self.off[rec.source] - self.step_db, 0.0)
Rules for learning rules:
- Keep the learned state in the rule object and create it in
reset(ctx); one rule object serves one run, and runs on other worlds start from scratch. - Draw random numbers from
ctx.rngonly; the contract kit fails a rule that touches NumPy's or Python's global random state. - Use only the
Observationand the records (OutcomeRecord,FailureRecord); a rule never sees the true SINR, the buildings or the cell load. memory_key(obs)lets a rule tag each attempt; the key comes back inOutcomeRecord.key. Until 1.10.0 only conditional handover passed it on (baseline attempts came back with-1); since 1.11.0 baseline and DAPS attempts carry it too.
Check it with the contract kit before using it in a study:
from handoverlab.testing import check_rule
check_rule(StepA3, {"step_db": 0.5, "ttt_s": 0.16}, reduces_to=("a3", {"off_db": 3.0, "ttt_s": 0.16}, {"step_db": 0.0}))
To train a model offline instead, export event logs with positions and outcomes
(export event logs) and load the trained model in reset.