Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ can tell whether the format it is looking at is one it understands.

### Added

- **`--device cpu|cuda` on `iqforge measure-leakage`**, defaulting to `cpu`.
CUDA is opt-in for new measurements and is never selected just because a GPU
is present; `cuda` errors when torch reports none. Published tables, the
parity gate, and `artifacts/*.json` stay on CPU. `TrainingResult.environment`
already recorded the device; the measurement path now stamps the device that
was actually requested.
- `iqforge measure-leakage` now accepts `--balance-by`, so the command path can
run the same nuisance-balancing setup that the published synthetic measurement
tables used.
Expand Down
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Then:

```bash
uv run iqforge train dataset/ --device cuda
uv run iqforge measure-leakage records/ --device cuda
```

`--device auto` picks CUDA when it is available and CPU otherwise; `--device
Expand All @@ -80,7 +81,10 @@ prints a warning that its numbers are not bit-comparable with CPU runs, and
`TrainingResult.environment` records the device, torch version, CUDA version,
and the numpy / scipy / sigmf versions windowing, normalisation and reading
depend on, so a results file can be checked later. The sweep scripts in `scripts/`
refuse to extend a checkpoint that was measured on a different environment.
and `scripts/parity_gate.py` do not pass `--device`, so published tables stay
on CPU. A CUDA leakage run is a new labeled measurement, not a replacement of
`artifacts/leakage_*.json`. The sweep scripts refuse to extend a checkpoint
that was measured on a different environment.

## Engineering conventions

Expand Down
16 changes: 8 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ After Now is done — still reliability-first:
- [ ] Hardware capture on **one** device (RTL-SDR / HackRF / Pluto) — parallel to
public-file work, not a blocker for it. One device proves one path, not all
integer/I/Q conventions.
- [ ] Optional CUDA for **new** measurements only. Published tables and the
- [x] Optional CUDA for **new** measurements only. Published tables and the
parity gate stay CPU; never re-measure those grids on GPU and call them
the same result. `iqforge train` stays CPU by default — CUDA is opt-in,
never a silent fallback because a GPU is present. Record `cpu` / `cuda`
in `TrainingResult.environment` (or equivalent) so two tables can be
told apart. A CUDA run is a new labeled measurement (the real-recording
leakage curve, when that happens), not a replacement of
`artifacts/leakage_*.json`. Do not claim GPU bit-equality. Do not
enlarge *n* after seeing results.
the same result. `iqforge train` and `iqforge measure-leakage` stay CPU
by default — CUDA is `--device cuda`, never a silent fallback because a
GPU is present. Record `cpu` / `cuda` in `TrainingResult.environment`
(or equivalent) so two tables can be told apart. A CUDA run is a new
labeled measurement (the real-recording leakage curve, when that
happens), not a replacement of `artifacts/leakage_*.json`. Do not claim
GPU bit-equality. Do not enlarge *n* after seeing results.
- [ ] Frequency-aware labeling (SPEC §5.3 deferred item)
- [x] **`--group-by`: keep related recordings together.** Shipped with two
schemes, `path:<regex>` and `csv:<file>`. Recordings sharing a key become
Expand Down
5 changes: 4 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,17 @@ iqforge measure-leakage <path> [--window 1024] [--stride 512]
[--dirname-level 1] [--group-by {path:<re>,csv:<file>,collection}]
[--balance-by <sigmf field>]
[--split 0.6,0.2,0.2] [--force] [--format {text,json}]
[--sweep stride]
[--sweep stride] [--device {auto,cpu,cuda}]
Runs `audit`, classifies into the six categories in methodology §6, then:
REFUSED / INCONCLUSIVE => exit 1; WOULD MEASURE => run the paired
measurement cell (recording-level and window-level) and report inflation.
Default operating point is split_seed=42 and train_seed=0.
--force overrides a refusal and keeps the overridden category in the
header so a pasted block cannot be mistaken for a clean run.
--sweep supports only `stride`; no SNR-injection flag (see 5.10).
--device: default `cpu`, same promise as `train`. CUDA is opt-in and
errors rather than falling back when no CUDA device is present.
Published tables and the parity gate stay on CPU.
```

---
Expand Down
25 changes: 23 additions & 2 deletions src/iqforge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,9 @@ def measure_leakage( # noqa: PLR0913 — flags match `audit` plus --force / --g
train_seeds: Annotated[
str, typer.Option("--train-seeds", help="Comma-separated training seeds")
] = DEFAULT_TRAIN_SEEDS,
device: Annotated[
str, typer.Option("--device", help="auto, cpu or cuda. Default cpu, deliberately")
] = "cpu",
) -> None:
"""Measure leakage inflation after running the refuse-path gate.

Expand All @@ -1175,6 +1178,9 @@ def measure_leakage( # noqa: PLR0913 — flags match `audit` plus --force / --g
run can be made cheaper deliberately and visibly, and the count is printed
with the result: a measurement whose sample size is not on the page cannot
be read.

`--device` defaults to CPU, the same promise as `train`. CUDA is opt-in and
never selected just because a GPU is present. Published tables stay CPU.
"""
if output_format not in ("text", "json"):
raise _fail(IQForgeError(f"--format must be text or json, got '{output_format}'."))
Expand Down Expand Up @@ -1233,6 +1239,13 @@ def measure_leakage( # noqa: PLR0913 — flags match `audit` plus --force / --g
raise typer.Exit(code=1)

if not _torch_available():
if device != "cpu":
raise _fail(
IQForgeError(
f"--device {device} was requested but torch is not installed. "
+ TORCH_REQUIRED.format(what="`iqforge measure-leakage`")
)
)
if sweep is not None:
raise _fail(IQForgeError(TORCH_REQUIRED.format(what="iqforge measure-leakage --sweep")))
return
Expand All @@ -1256,6 +1269,14 @@ def measure_leakage( # noqa: PLR0913 — flags match `audit` plus --force / --g
balance_by=balance_by,
)

def _measured(cells: list[GridCell]) -> list[Any]:
try:
return run_grid(
cells, split_seed_list, train_seed_list, verbose=False, device_choice=device
)
except IQForgeError as exc:
raise _fail(exc) from exc

if sweep == "stride":
strides = (1024, 768, 512, 256, 128)
cells = [
Expand All @@ -1276,7 +1297,7 @@ def measure_leakage( # noqa: PLR0913 — flags match `audit` plus --force / --g
)
for s in strides
]
runs = run_grid(cells, split_seed_list, train_seed_list, verbose=False)
runs = _measured(cells)
pairs = len(split_seed_list) * len(train_seed_list)
caption = (
f"Stride sweep over {pairs} seed pair(s) "
Expand Down Expand Up @@ -1319,7 +1340,7 @@ def measure_leakage( # noqa: PLR0913 — flags match `audit` plus --force / --g
return

cell = GridCell(records=path, spec=spec, stride=stride, label=f"stride={stride}")
runs = run_grid([cell], split_seed_list, train_seed_list, verbose=False)
runs = _measured([cell])
pairs = len(split_seed_list) * len(train_seed_list)
stats = cell_stats(runs)
if stats is None:
Expand Down
47 changes: 38 additions & 9 deletions src/iqforge/measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,26 @@ def split_counts(dataset: Path) -> tuple[int, int]:


def train_once(
dataset: Path, train_seed: int, epochs: int = EPOCHS
dataset: Path,
train_seed: int,
epochs: int = EPOCHS,
*,
device_choice: str | None = None,
) -> tuple[float, float, int, int]:
"""Train the baseline and return accuracies and split sizes."""
from iqforge.training import train_baseline
"""Train the baseline and return accuracies and split sizes.

`device_choice` is the same `cpu` / `cuda` / `auto` string `iqforge train`
takes. Left unset, training stays on CPU even when a GPU is present.
"""
from iqforge.training import DEFAULT_DEVICE, train_baseline

manifest = json.loads((dataset / "manifest.json").read_text(encoding="utf-8"))
result = train_baseline(dataset, epochs=epochs, seed=train_seed)
result = train_baseline(
dataset,
epochs=epochs,
seed=train_seed,
device_choice=DEFAULT_DEVICE if device_choice is None else device_choice,
)
return (
result.test_accuracy or 0.0,
result.final_train_accuracy,
Expand All @@ -287,14 +300,18 @@ def train_once(
)


def current_environment() -> dict[str, str]:
def current_environment(device_choice: str | None = None) -> dict[str, str]:
"""Device, torch, CUDA, and numeric-stack versions of this process.

Degrades rather than raising when torch is absent. The guards that use this
are about whether two sets of runs are comparable, and that question is
still worth answering in a torch-free install -- `audit` and the refuse
path both run there. Windowing and normalisation are numpy and scipy work
regardless, so those versions are reported either way.

`device_choice` is the training device that will produce the numbers, not
"whatever GPU happens to be plugged in". Unset means CPU, matching
`iqforge train` and `iqforge measure-leakage`.
"""
import numpy
import scipy
Expand All @@ -309,7 +326,8 @@ def current_environment() -> dict[str, str]:
from iqforge.training import DEFAULT_DEVICE, describe_environment, resolve_device
except ModuleNotFoundError:
return {"device": "none (torch not installed)", **base}
return describe_environment(resolve_device(DEFAULT_DEVICE))
choice = DEFAULT_DEVICE if device_choice is None else device_choice
return describe_environment(resolve_device(choice))


def check_environment(runs_path: Path) -> None:
Expand Down Expand Up @@ -481,6 +499,7 @@ def run_grid(
checkpoint: Callable[[list[Run]], None] | None = None,
epochs: int = EPOCHS,
verbose: bool = True,
device_choice: str | None = None,
) -> list[Run]:
"""Build both arms and train them, for every cell and seed pair.

Expand All @@ -492,9 +511,11 @@ def run_grid(
train_seeds: Seeds for weight init and batch order.
checkpoint: Called with the runs so far after every run.
epochs: Training length. Default is the published-table value.
device_choice: Training device (`cpu`, `cuda`, `auto`). Defaults to
CPU; a GPU that happens to be present is not used unless asked.
"""
runs: list[Run] = []
environment = current_environment()
environment = current_environment(device_choice)
work = Path(tempfile.mkdtemp(prefix="iqforge-measure-"))
total = len(cells) * len(split_seeds) * len(train_seeds) * 2
done = 0
Expand All @@ -513,7 +534,7 @@ def run_grid(
for train_seed in train_seeds:
t0 = time.time()
acc, train_acc, n_train, n_test = train_once(
dataset, train_seed, epochs=epochs
dataset, train_seed, epochs=epochs, device_choice=device_choice
)
done += 1
run = Run(
Expand Down Expand Up @@ -562,6 +583,7 @@ def measure_pair(
train_seed: int,
epochs: int = EPOCHS,
verbose: bool = True,
device_choice: str | None = None,
) -> tuple[Run, Run]:
"""One seed pair, both arms. The unit the LoRaIQ acceptance cell is."""
cell = GridCell(
Expand All @@ -570,7 +592,14 @@ def measure_pair(
stride=spec.stride,
label=f"stride={spec.stride}" if spec.stride is not None else "",
)
runs = run_grid([cell], [split_seed], [train_seed], epochs=epochs, verbose=verbose)
runs = run_grid(
[cell],
[split_seed],
[train_seed],
epochs=epochs,
verbose=verbose,
device_choice=device_choice,
)
rec = next(r for r in runs if r.strategy == "recording-level")
win = next(r for r in runs if r.strategy == "window-level")
return rec, win
Expand Down
5 changes: 3 additions & 2 deletions src/iqforge/training.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,9 @@ def train_baseline(
seed.
learning_rate: Adam learning rate.
on_epoch: Callback invoked at the end of every epoch.
device_choice: `auto`, `cpu` or `cuda`. Defaults to CPU; see
`DEFAULT_DEVICE` for why that is deliberate.
device_choice: `auto`, `cpu` or `cuda`. Defaults to CPU; see
`DEFAULT_DEVICE` for why that is deliberate. CUDA is never selected
just because a GPU is present.

Returns:
The results of the run.
Expand Down
37 changes: 37 additions & 0 deletions tests/test_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,40 @@ def blocked(name, *args, **kwargs):
assert "numpy" in env
assert "scipy" in env
assert "torch" not in env


def test_train_once_forwards_the_requested_device(tmp_path, monkeypatch):
"""measure-leakage's training path must honour --device, not ignore it."""
pytest.importorskip("torch")
from iqforge.measurement import train_once

(tmp_path / "manifest.json").write_text(
json.dumps({"splits": {"train": {"count": 1}, "test": {"count": 1}}}),
encoding="utf-8",
)
seen: dict[str, str] = {}

class _Result:
test_accuracy = 0.5
final_train_accuracy = 0.75
environment = {"device": "cuda"}

def fake_train_baseline(dataset, *, epochs, seed, device_choice="cpu"):
seen["device_choice"] = device_choice
return _Result()

monkeypatch.setattr("iqforge.training.train_baseline", fake_train_baseline)
train_once(tmp_path, train_seed=0, epochs=1, device_choice="cuda")
assert seen["device_choice"] == "cuda"

train_once(tmp_path, train_seed=0, epochs=1)
assert seen["device_choice"] == "cpu"


def test_current_environment_stamps_an_opt_in_cuda_device(monkeypatch):
torch = pytest.importorskip("torch")
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(torch.cuda, "get_device_name", lambda *args, **kwargs: "mocked-gpu")
assert current_environment("cpu")["device"] == "cpu"
assert current_environment("cuda")["device"] == "cuda"
assert current_environment()["device"] == "cpu"
Loading
Loading