Ship the learned-key pipeline, without which six figures cannot be rebuilt
The package was missing every script behind the KM (lrn.) curves and both learned table rows: exp_learned's driver, the merge that folds the learned rows into sec_compare.csv and refresh_summary.csv, and the report that reads the learned numbers back. It was also missing sec_keylen_perm.csv, so Fig. 3 could not be regenerated at all, and the two diagnostics that answer why a fixed key beats a learned one here and where a learned mask would win instead. The learned artifacts themselves are regenerated. They were trained on the cross-entropy alone, which drifts to disjoint sparse supports: 99 percent of each key's energy on about six of the 64 entries, so a digit is decided over a sixth of its period and the key set is a choice of support rather than a dense direction in R^L. They are now the regularized keys of Section V-C, and check_consistency asserts which of the two families the figures draw. Verified from inside this repository: replot_security.py rebuilds all seven result figures, make_tables.py reproduces both result tables, and check_consistency.py passes every check that does not need the manuscript. The README now lists what ships. Its run list, layout and figure map had none of the learned pipeline, named two tables the manuscript renders as prose, and gave Fig. 3 no data file for its permutation curve.
This commit is contained in:
+34
-12
@@ -55,8 +55,11 @@ chk("24 and 35 in tex", "$24$ to\n$35$~percent" in tex or "$24$ to $35$~percent"
|
||||
ew = [float(x["eve_wrong"]) for x in sn]
|
||||
ch = float(sn[0]["chance"])
|
||||
dev = max(abs(x - ch) for x in ew)
|
||||
chk("outsider at chance to 3.5e-4", dev < 3.6e-4, "max deviation %.2e" % dev)
|
||||
chk("3.5e-4 in tex", "$3.5\\times10^{-4}$" in tex, "searched tex",
|
||||
_ewl = [float(x["eve_wrong"]) for x in rows("sec_snr_learned.csv")]
|
||||
dev = max(dev, max(abs(x - ch) for x in _ewl))
|
||||
chk("outsider at chance to 4e-4, both families", dev < 4.0e-4,
|
||||
"max deviation %.2e" % dev)
|
||||
chk("4e-4 in tex", "$4\\times10^{-4}$" in tex, "searched tex",
|
||||
needs_tex=True)
|
||||
|
||||
# the main configuration's legitimate rate, the reference every later
|
||||
@@ -212,8 +215,8 @@ chk("learned support overlap 0.10",
|
||||
round(float(md["learned"]["mean_overlap"]), 2) == 0.10,
|
||||
md["learned"]["mean_overlap"])
|
||||
chk("degeneracy numbers in tex",
|
||||
"$5$ to $8$ of the $64$ entries" in tex
|
||||
and "overlapping by $0.10$" in " ".join(tex.split()),
|
||||
"$5$ to $8$ of the $64$ entries" in " ".join(tex.split())
|
||||
and "only $0.10$ of the smaller of any two such sets" in " ".join(tex.split()),
|
||||
"searched tex", needs_tex=True)
|
||||
|
||||
# --- why the permutation key is granted a shared permutation ---------
|
||||
@@ -224,7 +227,7 @@ chk("per-user permutation legitimate rate",
|
||||
abs(pv["per_user"] - 0.129) < 1e-3, "%.5f" % pv["per_user"])
|
||||
if HAVE_TEX:
|
||||
chk("quoted permutation cost in tex",
|
||||
"from $0.053$ to $0.129$" in " ".join(tex.split()),
|
||||
"at $0.129$ against $0.053$" in " ".join(tex.split()),
|
||||
"searched tex", needs_tex=True)
|
||||
|
||||
|
||||
@@ -299,22 +302,40 @@ chk("learned family tracks the structured one over the SNR range",
|
||||
for r in _sl),
|
||||
max(float(r["legit"]) / _sn[float(r["snr_db"])]
|
||||
for r in _sl)))
|
||||
chk("learned 0.064 at 10 dB",
|
||||
abs([float(r["legit"]) for r in _sl
|
||||
if float(r["snr_db"]) == 10.0][0] - 0.064) < 5e-4,
|
||||
"%.5f" % [float(r["legit"]) for r in _sl
|
||||
if float(r["snr_db"]) == 10.0][0])
|
||||
_l10 = [float(r["legit"]) for r in _sl if float(r["snr_db"]) == 10.0][0]
|
||||
chk("learned 0.061 at 10 dB", abs(_l10 - 0.061) < 5e-4, "%.5f" % _l10)
|
||||
# The learned curves must be the regularized keys, not the unpenalized
|
||||
# ones. Both are measured in sec_maskfam.csv and they differ by 0.003,
|
||||
# which is larger than the spread of either, so matching the right row
|
||||
# pins which family every figure draws. Cross-entropy alone drifts to a
|
||||
# slot allocation whose key space is a support rather than a sphere, so
|
||||
# drawing it would not support the key-space claim.
|
||||
_fam = {r["family"]: float(r["legit_ser"]) for r in rows("sec_maskfam.csv")}
|
||||
chk("the plotted learned family is the regularized one",
|
||||
abs(_l10 - _fam["learned_reg"]) < abs(_l10 - _fam["learned"])
|
||||
and abs(_l10 - _fam["learned_reg"]) < 1.5e-3,
|
||||
"plotted %.5f, reg %.5f, plain %.5f"
|
||||
% (_l10, _fam["learned_reg"], _fam["learned"]))
|
||||
|
||||
_bl = {int(r["K"]): float(r["ser_mask"])
|
||||
for r in rows("sec_brute_learned.csv")}
|
||||
chk("learned key resists a million random guesses",
|
||||
abs(_bl[1_000_000] - 0.72) < 5e-3, "%.4f" % _bl[1_000_000])
|
||||
abs(_bl[1_000_000] - 0.71) < 5e-3, "%.4f" % _bl[1_000_000])
|
||||
_kl = {(float(r["snr_db"]), int(r["n_frames"])): float(r["eve_ser"])
|
||||
for r in rows("kpa_learned.csv")}
|
||||
chk("learned key falls to known plaintext like the structured one",
|
||||
_kl[(10.0, 4)] < 0.08 and _kl[(10.0, 1)] > 0.9,
|
||||
"N=1 %.3f, N=4 %.4f" % (_kl[(10.0, 1)], _kl[(10.0, 4)]))
|
||||
|
||||
_rs = {float(r["snr_db"]): float(r["ter_legit"])
|
||||
for r in rows("real_sec_ter.csv")}
|
||||
_rl = {float(r["snr_db"]): float(r["ter_legit"])
|
||||
for r in rows("real_sec_ter_learned.csv")}
|
||||
_rat = [_rl[k] / _rs[k] for k in _rs]
|
||||
chk("learned keeps its uniform-source distance on real text",
|
||||
all(1.10 < v < 1.25 for v in _rat),
|
||||
"ratio %.2f to %.2f" % (min(_rat), max(_rat)))
|
||||
|
||||
# --- trends, which the value assertions above cannot see ---------------
|
||||
_snr = rows("sec_snr.csv")
|
||||
_lg = [float(r["legit"]) for r in _snr]
|
||||
@@ -387,7 +408,8 @@ for _k, _c in [("V8 cross-period remainder", 0.0005),
|
||||
and float(_vm[_k]["abs_err"]) <= _c,
|
||||
_vm[_k]["empirical"] if _k in _vm else "row missing")
|
||||
chk("format-matched OMA quoted as 0.055",
|
||||
"$0.055$ at $10$~dB against the proposed" in tex, "Section VI-B",
|
||||
"$0.055$ at $10$~dB" in tex and "the proposed $0.053$" in tex,
|
||||
"Section VI-B",
|
||||
needs_tex=True)
|
||||
|
||||
# --- tables against their generator -----------------------------------
|
||||
|
||||
@@ -28,7 +28,7 @@ from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
from exp_full import MAIN_D, base_keys, get_model, main_model
|
||||
from exp_full import MAIN_D, base_keys, get_model_reg, main_model
|
||||
from sse_lib import DEVICE, rayleigh_gain, snr_to_sigma2, write_csv
|
||||
|
||||
DATA = Path(__file__).resolve().parents[1] / "data"
|
||||
@@ -117,7 +117,9 @@ def run():
|
||||
torch.manual_seed(SEED)
|
||||
rows = []
|
||||
_sweep(main_model(), "structured", rows) # keys frozen to Walsh
|
||||
_sweep(get_model(P=4, vu=16, d=MAIN_D, U=4, iters=4000, seed=1),
|
||||
# the regularized keys of Section V-C, which are the learned family
|
||||
# every figure draws; the unpenalized ones are a slot allocation
|
||||
_sweep(get_model_reg(P=4, vu=16, d=MAIN_D, U=4, iters=4000, seed=1),
|
||||
"learned", rows) # keys trained in R^L
|
||||
write_csv(DATA / "family_enum.csv",
|
||||
["family", "keying", "snr_db", "n_frames",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Does learning the mask buy anything, and where would it?
|
||||
|
||||
Nothing in the manuscript rests on this; it answers a design question
|
||||
the paper does not raise. Output in data/jscc.csv.
|
||||
|
||||
What the run found, at 10 dB over three training seeds. A: with one
|
||||
user the mask does not matter, a Walsh row and no mask at all landing
|
||||
at 0.0133 and 0.0132 against 0.0148 for a learned key, so the mask
|
||||
carries no part of the source-channel map and only separates users.
|
||||
B: at the six key lengths where truncated Walsh rows are not exactly
|
||||
orthogonal, learning wins once, at L=14 with kappa 0.048, and loses at
|
||||
L=16, 20, 22 and 24 where the rows ARE orthogonal. C: raising the load
|
||||
to U=20 at L=16 gives learning its second win, by 1e-5 on an error
|
||||
rate of 0.9998, which is no win at all because both families have
|
||||
already collapsed. Every other point is a tie inside the seed spread.
|
||||
|
||||
So the room for a learned mask is real but narrow, and it is where
|
||||
exact orthogonality does not exist rather than where the load is high.
|
||||
A learned mask beating a fixed one wants a loss that is not digit
|
||||
cross-entropy, or a source that is not uniform, or a channel that is
|
||||
not a scalar the receiver divides out.
|
||||
|
||||
A joint source-channel view says a learned mask should beat a fixed one,
|
||||
since the fixed one lies inside the search space. It does not here, and
|
||||
these experiments say why, and where the picture changes.
|
||||
|
||||
A. What job does the mask actually do? Run one user. With a single user
|
||||
there is nobody to separate from, so if the mask carried any part of
|
||||
the source-channel map its choice would still matter. Compare a Walsh
|
||||
row, no mask at all, and a learned key, each with the codebook
|
||||
trained around it. Equal error rates mean the mask is not part of
|
||||
that map: the codebook is, and the mask only separates users. This
|
||||
also has a one-line proof. A unit-modulus key has m^2 = 1, so it
|
||||
cancels from the signal self-term and from the noise projection
|
||||
alike, and the score is unchanged.
|
||||
|
||||
B. Where is the structured family no longer optimal? The construction
|
||||
supplies exactly orthogonal unit-modulus rows only at the lengths
|
||||
where truncation preserves orthogonality. At L = 6, 10, 14, 18, 20
|
||||
and 22 the truncated rows correlate, so no exactly orthogonal
|
||||
unit-modulus family is available and learning has room to find a
|
||||
better packing. Every length is run at several seeds, because a
|
||||
single training run is not evidence of a family being better.
|
||||
|
||||
C. Overload. Beyond U = L - 1 no orthogonal set of non-constant rows
|
||||
exists at all, so the structured family has to reuse rows and the
|
||||
comparison is decided by whatever packing learning finds.
|
||||
|
||||
Run on a GPU host: python code/diag_jscc.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from exp_full import (MAIN_D, base_keys, get_model_reg, # noqa: E402
|
||||
mean_abs_xcorr)
|
||||
from sse_lib import DATA, DEVICE, SSE, eval_ser_sse, set_seed # noqa: E402
|
||||
import sse_lib as L # noqa: E402
|
||||
|
||||
SNR = 10.0
|
||||
SEEDS = [1, 2, 3]
|
||||
FRAMES = 400_000
|
||||
|
||||
|
||||
def train_with_key(W0, d, P, vu, U, iters=4000, seed=1, frozen=True):
|
||||
"""Train the codebook around a given key, optionally holding it."""
|
||||
set_seed(seed)
|
||||
m = SSE(P=P, vu=vu, d=d, users=U).to(DEVICE)
|
||||
with torch.no_grad():
|
||||
m.W.copy_(W0.to(DEVICE))
|
||||
m.W.requires_grad_(not frozen)
|
||||
L.train_sse(m, iters=iters, batch=256, lr=3e-3, seed=seed)
|
||||
m.calibrate_power()
|
||||
return m
|
||||
|
||||
|
||||
def ms(vals):
|
||||
"""Mean and, when there is more than one, the sample spread."""
|
||||
if len(vals) == 1:
|
||||
return vals[0], 0.0
|
||||
return statistics.mean(vals), statistics.stdev(vals)
|
||||
|
||||
|
||||
def part_a(rows):
|
||||
"""One user: does the choice of mask matter at all?"""
|
||||
print("-- A. one user, d=%d, L=%d, %d seeds --"
|
||||
% (MAIN_D, MAIN_D // 4, len(SEEDS)))
|
||||
Lp = MAIN_D // 4
|
||||
cases = [("Walsh row", base_keys(1, Lp), True),
|
||||
("all ones (no mask)", torch.ones(1, Lp), True),
|
||||
("learned, free", base_keys(1, Lp), False)]
|
||||
for name, W0, frozen in cases:
|
||||
v = [eval_ser_sse(train_with_key(W0, MAIN_D, 4, 16, 1, seed=s,
|
||||
frozen=frozen),
|
||||
[SNR], frames=FRAMES)[0] for s in SEEDS]
|
||||
mu, sd = ms(v)
|
||||
print(" %-20s SER %.5f +- %.5f" % (name, mu, sd))
|
||||
rows.append(["A one user", name, "%.5f" % mu, "%.5f" % sd, "", ""])
|
||||
|
||||
|
||||
def part_b(rows):
|
||||
"""Key lengths where no exactly orthogonal unit-modulus family exists."""
|
||||
print("\n-- B. key length, U=4, %d seeds --" % len(SEEDS))
|
||||
print(" %-4s %-9s %-18s %-18s %s"
|
||||
% ("L", "kappa str", "structured SER", "learned SER", "verdict"))
|
||||
for Lp in [6, 8, 10, 12, 14, 16, 18, 20, 22, 24]:
|
||||
d = 4 * Lp
|
||||
try:
|
||||
W0 = base_keys(4, Lp)
|
||||
except ValueError as e:
|
||||
print(" %-4d skipped: %s" % (Lp, e))
|
||||
continue
|
||||
ks = mean_abs_xcorr(W0)
|
||||
vs = [eval_ser_sse(train_with_key(W0, d, 4, 16, 4, seed=s),
|
||||
[SNR], frames=FRAMES)[0] for s in SEEDS]
|
||||
vl = [eval_ser_sse(get_model_reg(P=4, vu=16, d=d, U=4, iters=4000,
|
||||
seed=s),
|
||||
[SNR], frames=FRAMES)[0] for s in SEEDS]
|
||||
(mus, sds), (mul, sdl) = ms(vs), ms(vl)
|
||||
# a win only counts when it clears the spread of both runs
|
||||
win = "learned" if mul + sdl < mus - sds else (
|
||||
"structured" if mus + sds < mul - sdl else "tie")
|
||||
print(" %-4d %-9.5f %.5f +- %.5f %.5f +- %.5f %s"
|
||||
% (Lp, ks, mus, sds, mul, sdl, win))
|
||||
rows.append(["B key length", "L=%d" % Lp, "%.5f" % mus,
|
||||
"%.5f" % sds, "%.5f" % mul, "%.5f/%s" % (ks, win)])
|
||||
|
||||
|
||||
def part_c(rows):
|
||||
"""Overload: more users than the construction has orthogonal rows."""
|
||||
print("\n-- C. load at L=16, %d seeds --" % len(SEEDS))
|
||||
Lp, d = 16, 64
|
||||
print(" %-4s %-9s %-18s %-18s %s"
|
||||
% ("U", "kappa str", "structured SER", "learned SER", "verdict"))
|
||||
for U in [4, 8, 12, 15, 16, 20]:
|
||||
try:
|
||||
W0 = base_keys(U, Lp)
|
||||
except ValueError:
|
||||
# beyond the orthogonal rows the construction has to reuse
|
||||
# them, which is the honest structured fallback
|
||||
H = base_keys(Lp - 1, Lp)
|
||||
W0 = H[[i % (Lp - 1) for i in range(U)]]
|
||||
ks = mean_abs_xcorr(W0)
|
||||
vs = [eval_ser_sse(train_with_key(W0, d, 4, 16, U, seed=s),
|
||||
[SNR], frames=FRAMES)[0] for s in SEEDS]
|
||||
vl = [eval_ser_sse(get_model_reg(P=4, vu=16, d=d, U=U, iters=4000,
|
||||
seed=s),
|
||||
[SNR], frames=FRAMES)[0] for s in SEEDS]
|
||||
(mus, sds), (mul, sdl) = ms(vs), ms(vl)
|
||||
win = "learned" if mul + sdl < mus - sds else (
|
||||
"structured" if mus + sds < mul - sdl else "tie")
|
||||
print(" %-4d %-9.5f %.5f +- %.5f %.5f +- %.5f %s"
|
||||
% (U, ks, mus, sds, mul, sdl, win))
|
||||
rows.append(["C load", "U=%d" % U, "%.5f" % mus, "%.5f" % sds,
|
||||
"%.5f" % mul, "%.5f/%s" % (ks, win)])
|
||||
|
||||
|
||||
def main():
|
||||
print("device", DEVICE)
|
||||
rows = []
|
||||
part_a(rows)
|
||||
part_b(rows)
|
||||
part_c(rows)
|
||||
out = DATA / "jscc.csv"
|
||||
with open(out, "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["part", "case", "structured_ser", "structured_sd",
|
||||
"learned_ser", "kappa_and_verdict"])
|
||||
w.writerows(rows)
|
||||
print("\n[csv]", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Why do structured keys beat learned ones on the legitimate error rate?
|
||||
|
||||
The gap is 0.053 against 0.064 at 10 dB, and a reader may reasonably
|
||||
suspect that the learned keys are handicapped, since they alone are
|
||||
trained under the channel while the structured ones are fixed by
|
||||
construction. This measures where the gap comes from.
|
||||
|
||||
Three questions, one experiment each.
|
||||
|
||||
1. Is the gap a channel-adaptation failure? If it were, the two families
|
||||
would differ by more at some channel qualities than at others. The
|
||||
ratio across the SNR sweep answers this from data already on disk.
|
||||
|
||||
2. Is the structured key a point that training can improve on? Start
|
||||
training from the Walsh-Hadamard keys with the masks unfrozen and let
|
||||
Adam move them. If the structured point is a genuine optimum, the
|
||||
error rate stays or rises; if training is merely under-converged from
|
||||
its random start, it falls.
|
||||
|
||||
3. What does the learned key lose? Two candidates, measured directly:
|
||||
residual cross-user correlation, which the analysis names as the
|
||||
first-order leakage and interference term, and departure from unit
|
||||
modulus, which spreads the key energy unevenly across the entries so
|
||||
that a digit is decided over an effectively shorter support.
|
||||
|
||||
Run: python code/diag_whygap.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from exp_full import (MAIN_D, base_keys, get_model, main_model, # noqa: E402
|
||||
mean_abs_xcorr)
|
||||
from sse_lib import DATA, DEVICE, eval_ser_sse # noqa: E402
|
||||
import sse_lib as L # noqa: E402
|
||||
|
||||
FRAMES = 300_000
|
||||
SNR = 10.0
|
||||
|
||||
|
||||
def modulus_stats(W):
|
||||
"""How far the key entries are from unit modulus, per user.
|
||||
|
||||
A unit-modulus key puts the same energy on every entry, so the
|
||||
signal term does not depend on the key and every entry of the period
|
||||
carries its share of the decision. The ratio below is the effective
|
||||
fraction of the L entries the key actually uses, by the
|
||||
participation ratio (sum a^2)^2 / (L sum a^4) with a the entry
|
||||
magnitudes. It is one for a unit-modulus key and 1/L for a key that
|
||||
puts everything on one entry.
|
||||
"""
|
||||
a2 = W.pow(2)
|
||||
pr = a2.sum(dim=1).pow(2) / (W.shape[1] * a2.pow(2).sum(dim=1))
|
||||
return pr
|
||||
|
||||
|
||||
def report(name, model, rows):
|
||||
W = model.masks().detach().cpu()
|
||||
ser = eval_ser_sse(model, [SNR], frames=FRAMES)[0]
|
||||
kap = mean_abs_xcorr(model.masks().detach())
|
||||
pr = modulus_stats(W)
|
||||
print("%-24s SER %.5f kappa-bar %.5f entry use %.3f"
|
||||
% (name, ser, kap, float(pr.mean())))
|
||||
rows.append([name, "%.5f" % ser, "%.5f" % kap, "%.4f" % float(pr.mean())])
|
||||
return ser
|
||||
|
||||
|
||||
def main():
|
||||
rows = []
|
||||
print("main configuration d=%d, L=%d, 10 dB, %d frames\n"
|
||||
% (MAIN_D, MAIN_D // 4, FRAMES))
|
||||
|
||||
print("-- the two families as the paper plots them --")
|
||||
fix = main_model()
|
||||
s_fix = report("structured (frozen)", fix, rows)
|
||||
free = get_model(iters=4000)
|
||||
s_free = report("learned (free start)", free, rows)
|
||||
|
||||
print("\n-- question 2: can training improve on the structured key? --")
|
||||
# same trainer, same iterations, same seed, but the masks start at
|
||||
# the Walsh-Hadamard point and are free to move
|
||||
from sse_lib import SSE, set_seed
|
||||
set_seed(1)
|
||||
m = SSE(P=4, vu=16, d=MAIN_D, users=4).to(DEVICE)
|
||||
with torch.no_grad():
|
||||
m.W.copy_(base_keys(4, MAIN_D // 4).to(DEVICE))
|
||||
m.W.requires_grad_(True)
|
||||
L.train_sse(m, iters=4000, batch=256, lr=3e-3, seed=1)
|
||||
m.calibrate_power()
|
||||
s_warm = report("learned (Walsh start)", m, rows)
|
||||
|
||||
print("\nreading:")
|
||||
print(" free start %+.1f percent against the structured key"
|
||||
% (100.0 * (s_free - s_fix) / s_fix))
|
||||
print(" Walsh start %+.1f percent against the structured key"
|
||||
% (100.0 * (s_warm - s_fix) / s_fix))
|
||||
if s_warm > s_fix:
|
||||
print(" training moves off the structured point and pays for it,")
|
||||
print(" so the structured key is not a point learning improves on.")
|
||||
else:
|
||||
print(" training improves on the structured point, so the gap is")
|
||||
print(" under-convergence from the random start, not geometry.")
|
||||
|
||||
out = DATA / "whygap.csv"
|
||||
with open(out, "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["family", "legit_ser", "kappa_bar", "entry_use"])
|
||||
w.writerows(rows)
|
||||
print("\n[csv]", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+6
-3
@@ -194,11 +194,14 @@ def stage_N():
|
||||
The structured family is enumerable and closed under the elementwise
|
||||
product, the learned one is neither, so the paper reports both. This
|
||||
stage runs the same SNR sweep as stage_A with the keys trained in
|
||||
R^L instead of frozen to Walsh-Hadamard rows, at the same frame
|
||||
count, so the two are directly comparable.
|
||||
R^L under the regularized loss instead of frozen to Walsh-Hadamard
|
||||
rows, at the same frame count, so the two are directly comparable.
|
||||
"""
|
||||
print("[N] security vs SNR, learned key family ...")
|
||||
m = get_model(P=4, vu=16, d=MAIN_D, U=4, iters=4000, seed=1)
|
||||
# the regularized loss of Section V-C, not the cross-entropy alone:
|
||||
# see exp_learned.learned_model for why the unpenalized keys are not
|
||||
# the family the paper claims
|
||||
m = get_model_reg(P=4, vu=16, d=MAIN_D, U=4, iters=4000, seed=1)
|
||||
snr = [float(v) for v in range(0, 21, 2)]
|
||||
frames = 800_000
|
||||
legit = eval_ser_sse(m, snr, frames=frames)
|
||||
|
||||
+54
-11
@@ -11,7 +11,8 @@ ones.
|
||||
|
||||
Every evaluation mirrors its structured counterpart exactly: same SNR,
|
||||
same frame counts, same seeds, same evaluators. Only the key family
|
||||
differs.
|
||||
differs. The learned keys are the regularized ones of Section V-C, not
|
||||
the unpenalized ones: see learned_model below for why.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,16 +22,28 @@ from pathlib import Path
|
||||
import torch
|
||||
|
||||
import exp_kpa
|
||||
import exp_refresh
|
||||
from exp_full import (MAIN_D, eval_ser_eve, eval_ser_jam, eve_wrong_mask,
|
||||
get_model, mean_abs_xcorr, oma_ser_keylen)
|
||||
get_model_reg, mean_abs_xcorr, oma_ser_keylen)
|
||||
from sse_lib import DATA, DEVICE, eval_ser_sse, write_csv
|
||||
|
||||
SEED = 1
|
||||
|
||||
|
||||
def learned_model(d=MAIN_D, P=4, vu=16, U=4, iters=4000, seed=SEED):
|
||||
"""The learned counterpart of main_model: same everything, keys free."""
|
||||
return get_model(P=P, vu=vu, d=d, U=U, iters=iters, seed=seed)
|
||||
"""The learned counterpart of main_model: same everything, keys free.
|
||||
|
||||
The keys are trained under the two penalties of the regularized loss
|
||||
rather than under the cross-entropy alone. Cross-entropy on its own
|
||||
has an attractor at disjoint sparse supports, which is an orthogonal
|
||||
slot allocation: the keys it reaches carry 99 percent of their
|
||||
energy on about six of the L entries, so a digit is decided over a
|
||||
sixth of its period and the key set is a choice of support rather
|
||||
than a dense direction in R^L. The penalties are the design of
|
||||
Section V-C and hold that drift back, which is the realization the
|
||||
paper claims for the learned family.
|
||||
"""
|
||||
return get_model_reg(P=P, vu=vu, d=d, U=U, iters=iters, seed=seed)
|
||||
|
||||
|
||||
def keylen():
|
||||
@@ -56,7 +69,7 @@ def jamming():
|
||||
"""Fig. 4's learned curves."""
|
||||
print("[learned] jamming ...")
|
||||
m = learned_model()
|
||||
jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0]
|
||||
jsr = [float(v) for v in range(-10, 21, 2)] # the grid Fig. 4's other curves use
|
||||
blind = eval_ser_jam(m, 10.0, jsr, frames=500_000, mode="blind", target=0)
|
||||
matched = eval_ser_jam(m, 10.0, jsr, frames=500_000, mode="matched",
|
||||
target=0)
|
||||
@@ -106,7 +119,7 @@ def refresh():
|
||||
W0, B0 = m.W.detach().clone(), m.B.detach().clone()
|
||||
base = eval_ser_sse(m, [10.0], frames=300_000)[0]
|
||||
out = []
|
||||
for b in range(8):
|
||||
for b in range(exp_refresh.BLOCKS):
|
||||
g = torch.Generator(device=DEVICE).manual_seed(5150 + b)
|
||||
xi = torch.randperm(m.L, generator=g, device=DEVICE)
|
||||
eps = torch.randint(2, (m.L,), generator=g, device=DEVICE) * 2.0 - 1.0
|
||||
@@ -133,11 +146,15 @@ def compare():
|
||||
print("[learned] scheme comparison ...")
|
||||
m = learned_model()
|
||||
F = 300_000
|
||||
legit = eval_ser_sse(m, [10.0], frames=F)[0]
|
||||
out = eval_ser_eve(m, eve_wrong_mask(m.users, m.L,
|
||||
seed=20260813).to(DEVICE),
|
||||
[10.0], frames=F)[0]
|
||||
ins = eval_ser_eve(m, m.masks().detach().roll(1, 0), [10.0], frames=F)[0]
|
||||
# the table caption states a user-1 convention and every structured
|
||||
# row honours it, so this row uses the same evaluator rather than the
|
||||
# four-user average eval_ser_eve returns
|
||||
from exp_full import eval_scheme
|
||||
legit = eval_scheme(m, 10.0, F)
|
||||
out = eval_scheme(m, 10.0, F,
|
||||
rx_masks=eve_wrong_mask(m.users, m.L,
|
||||
seed=20260813).to(DEVICE))
|
||||
ins = eval_scheme(m, 10.0, F, rx_masks=m.masks().detach().roll(1, 0))
|
||||
jam = eval_ser_jam(m, 10.0, [0.0], frames=F, mode="blind", target=0)[0]
|
||||
write_csv(DATA / "compare_learned.csv",
|
||||
["scheme", "legit_ser", "eve_out", "eve_in", "jam0_ser"],
|
||||
@@ -235,3 +252,29 @@ def real():
|
||||
for n, blob in saved.items():
|
||||
(DATA / n).write_bytes(blob)
|
||||
print(" learned artifacts written, structured ones restored")
|
||||
|
||||
|
||||
def keylen_perm():
|
||||
"""Fig. 3's permutation-key curves.
|
||||
|
||||
The permutation scheme keeps the masks public and hides the frame
|
||||
order instead, so its legitimate receiver inverts the permutation
|
||||
and decodes as the public-mask receiver does, while its eavesdropper
|
||||
holds the public masks but not the order. Both are swept over the
|
||||
same key lengths as the structured family so the figure carries the
|
||||
comparison scheme at every point rather than only at L = 64.
|
||||
"""
|
||||
import torch
|
||||
from exp_full import (eval_scheme, eval_scheme_permuted_eve, main_model)
|
||||
print("[learned] key length, permutation key ...")
|
||||
rows = []
|
||||
for d in [32, 48, 64, 80, 96, 128, 192, 256]:
|
||||
m = main_model(d=d)
|
||||
gp = torch.Generator().manual_seed(11)
|
||||
perms = torch.randperm(d, generator=gp)[None].repeat(m.users, 1)
|
||||
lg = eval_scheme(m, 10.0, 500_000, perms=perms)
|
||||
ev = eval_scheme_permuted_eve(m, 10.0, 500_000, perms)
|
||||
rows.append((m.L, d, lg, ev))
|
||||
print(" L=%3d legit %.4f eve %.4f" % (m.L, lg, ev))
|
||||
write_csv(DATA / "sec_keylen_perm.csv",
|
||||
["L", "d", "legit_ser", "eve_ser"], rows)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Fold the learned-key rows into the two table sources.
|
||||
|
||||
Table IV reads sec_compare.csv and Table V reads refresh_summary.csv,
|
||||
and both are written by the structured stages, which know nothing about
|
||||
the learned family. Its rows were appended by hand, so a rerun of the
|
||||
learned stages left the tables behind. This does the fold, so both files
|
||||
are derived from data/ like every other table source.
|
||||
|
||||
Run after code/run_learned_reg.py, before code/make_tables.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
DATA = Path(__file__).resolve().parents[1] / "data"
|
||||
|
||||
|
||||
def read(name):
|
||||
with open(DATA / name) as f:
|
||||
r = csv.DictReader(f)
|
||||
return r.fieldnames, list(r)
|
||||
|
||||
|
||||
def write(name, fields, rows):
|
||||
with open(DATA / name, "w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=fields)
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
|
||||
|
||||
def upsert(rows, key, value, row):
|
||||
"""Replace the row carrying key==value, or append it."""
|
||||
for i, r in enumerate(rows):
|
||||
if r[key] == value:
|
||||
rows[i] = row
|
||||
return rows
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
# Table IV: the learned scheme row, measured by exp_learned.compare
|
||||
fields, rows = read("sec_compare.csv")
|
||||
_, learned = read("compare_learned.csv")
|
||||
assert len(learned) == 1, "compare_learned.csv should carry one row"
|
||||
rows = upsert(rows, "scheme", "proposed_learned",
|
||||
{k: learned[0][k] for k in fields})
|
||||
write("sec_compare.csv", fields, rows)
|
||||
print("sec_compare.csv proposed_learned jam0 %s"
|
||||
% learned[0]["jam0_ser"])
|
||||
|
||||
# Table V: the learned refresh row, averaged over the blocks that
|
||||
# exp_learned.refresh measured, at the same entropy as the
|
||||
# structured refresh because the invariance group is the same
|
||||
fields, rows = read("refresh_summary.csv")
|
||||
_, blocks = read("refresh_learned.csv")
|
||||
lg = sum(float(r["legit_ser"]) for r in blocks) / len(blocks)
|
||||
ev = sum(float(r["eve_ser"]) for r in blocks) / len(blocks)
|
||||
ent = next(r["entropy_bits"] for r in rows
|
||||
if r["scheme"] == "Invariant, KM (str.)")
|
||||
rows = upsert(rows, "scheme", "Invariant, KM (lrn.)",
|
||||
{"scheme": "Invariant, KM (lrn.)",
|
||||
"legit": "%.6f" % lg, "eve": "%.6f" % ev,
|
||||
"entropy_bits": ent})
|
||||
write("refresh_summary.csv", fields, rows)
|
||||
print("refresh_summary.csv Invariant, KM (lrn.) legit %.5f eve %.5f"
|
||||
% (lg, ev))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+22
-11
@@ -365,7 +365,18 @@ def fig_keylen():
|
||||
ax.semilogy(col(rl, "L", int), col(rl, "legit_ser"), **STY["km_lrn"], label=LBL["legit_learned"])
|
||||
op = [(l, v) for l, v in zip(x, col(r, "oma")) if not math.isnan(v)]
|
||||
ax.semilogy([p[0] for p in op], [p[1] for p in op], **STY["oma"], label=LBL["oma"])
|
||||
ax.semilogy(x, col(r, "eve_ser"), **STY["eve"], label=LBL["eve_key"])
|
||||
ax.semilogy(x, col(r, "eve_ser"), **STY["eve"], markevery=(0, 2),
|
||||
label=LBL["eve_key"], **UNDER)
|
||||
# the permutation key shares this physical layer, so its legitimate
|
||||
# curve lies on the structured one and appears at every key length
|
||||
# rather than only in the tables. Its outsider measures 0.99997 to
|
||||
# 0.99999 and would lie on the outsider curve already drawn, in the
|
||||
# same style as this one and with no legend entry of its own, so the
|
||||
# caption says where it sits instead.
|
||||
rp = load("sec_keylen_perm.csv")
|
||||
assert min(float(r["eve_ser"]) for r in rp) > 0.999, "the permutation outsider left the random-guess level"
|
||||
ax.semilogy(col(rp, "L", int), col(rp, "legit_ser"), **STY["perm"],
|
||||
markevery=(1, 2), label=LBL["perm"], **OVER)
|
||||
ax.set_ylim(top=22.0) # headroom above the flat eavesdropper curve
|
||||
# an error rate cannot exceed one, and the room below the data holds
|
||||
# the legend, since every curve decays to the right
|
||||
@@ -409,7 +420,7 @@ def fig_jam():
|
||||
# axis-spanning lines, so it cannot move the legend off this one, and
|
||||
# a reference drawn along the legend frame reads as part of the box.
|
||||
ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), lw=0.9, zorder=0)
|
||||
ax.set_ylim(6e-3, 1.4)
|
||||
ax.set_ylim(2.5e-2, 1.4)
|
||||
# a log axis spanning little more than a decade prints minor labels
|
||||
# like 6x10^-1 that consume the left margin, so only the decades are
|
||||
# labelled
|
||||
@@ -478,20 +489,20 @@ def fig_real():
|
||||
r = load("real_sec_ter.csv")
|
||||
x = col(r, "snr_db")
|
||||
fig, ax = plt.subplots()
|
||||
# insider and outsider still nearly coincide and are layered; the
|
||||
# legitimate and OMA curves are separate at this frame
|
||||
# two pairs nearly coincide here, the two legitimate realizations
|
||||
# within a fifth of each other and the two adversaries both at the
|
||||
# top, so each pair is layered and its markers staggered
|
||||
ax.semilogy(x, col(r, "ter_legit"), **STY["km_str"],
|
||||
markevery=(0, 2), label=LBL["legit"], **UNDER)
|
||||
markevery=(0, 3), label=LBL["legit"], **UNDER)
|
||||
rt = load("real_sec_ter_learned.csv")
|
||||
ax.semilogy(col(rt, "snr_db"), col(rt, "ter_legit"),
|
||||
**STY["km_lrn"], markevery=(1, 2),
|
||||
label=LBL["legit_learned"])
|
||||
ax.semilogy(col(rt, "snr_db"), col(rt, "ter_legit"), **STY["km_lrn"],
|
||||
markevery=(1, 3), label=LBL["legit_learned"], **OVER)
|
||||
ax.semilogy(x, col(r, "ter_oma"), **STY["oma"],
|
||||
markevery=(1, 2), label=LBL["oma"], **OVER)
|
||||
markevery=(2, 3), label=LBL["oma"])
|
||||
ax.semilogy(x, col(r, "ter_insider"), **STY["insider"],
|
||||
markevery=(0, 2), label=LBL["insider"], **UNDER)
|
||||
markevery=(0, 3), label=LBL["insider"], **UNDER)
|
||||
ax.semilogy(x, col(r, "ter_eve"), **STY["eve"],
|
||||
markevery=(1, 2), label=LBL["outsider"], **OVER)
|
||||
markevery=(2, 3), label=LBL["outsider"], **OVER)
|
||||
ax.set_xlabel("SNR (dB)")
|
||||
ax.set_ylabel("TER")
|
||||
ax.set_xlim(min(x), max(x))
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Every learned-family number the manuscript quotes, read from data/.
|
||||
|
||||
Switching the learned family from the unpenalized keys to the
|
||||
regularized ones of Section V-C moves every learned value in the paper.
|
||||
This prints them next to their structured counterparts so the sentences
|
||||
that carry them can be updated from one place, and so a later rerun can
|
||||
be checked against what is printed.
|
||||
|
||||
Run: python code/report_learned.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
DATA = Path(__file__).resolve().parents[1] / "data"
|
||||
|
||||
|
||||
def rows(name):
|
||||
with open(DATA / name) as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
def at(rs, key, val, col):
|
||||
for r in rs:
|
||||
if abs(float(r[key]) - val) < 1e-9:
|
||||
return float(r[col])
|
||||
raise KeyError("%s=%s not in the sweep" % (key, val))
|
||||
|
||||
|
||||
def main():
|
||||
print("== Fig. 2, SER against SNR ==")
|
||||
s = rows("sec_snr.csv")
|
||||
l = rows("sec_snr_learned.csv")
|
||||
ratios = []
|
||||
for a, b in zip(s, l):
|
||||
r = float(b["legit"]) / float(a["legit"])
|
||||
ratios.append(r)
|
||||
print(" %5s dB str %.5f lrn %.5f ratio %.3f"
|
||||
% (a["snr_db"], float(a["legit"]), float(b["legit"]), r))
|
||||
print(" ratio range %.3f to %.3f" % (min(ratios), max(ratios)))
|
||||
|
||||
print("\n== Fig. 3, SER against key length ==")
|
||||
s = rows("sec_keylen.csv")
|
||||
l = rows("sec_keylen_learned.csv")
|
||||
for a, b in zip(s, l):
|
||||
print(" L=%-4s str %.5f lrn %.5f ratio %.3f kappa %.5f"
|
||||
% (a["L"], float(a["legit_ser"]), float(b["legit_ser"]),
|
||||
float(b["legit_ser"]) / float(a["legit_ser"]),
|
||||
float(b["mask_xcorr"])))
|
||||
print(" learned outsider floor %.5f"
|
||||
% min(float(r["eve_ser"]) for r in l))
|
||||
|
||||
print("\n== Fig. 4, jamming at JSR 0 dB ==")
|
||||
print(" str blind %.4f" % at(rows("sec_jam.csv"), "jsr_db", 0.0,
|
||||
"blind"))
|
||||
print(" lrn blind %.4f" % at(rows("sec_jam_learned.csv"), "jsr_db",
|
||||
0.0, "blind"))
|
||||
|
||||
print("\n== Fig. 6, best of K=1e6 guesses ==")
|
||||
print(" str %.4f" % at(rows("sec_brute_cmp.csv"), "K", 1e6, "ser_mask"))
|
||||
print(" lrn %.4f" % at(rows("sec_brute_learned.csv"), "K", 1e6,
|
||||
"ser_mask"))
|
||||
|
||||
print("\n== Fig. 7, known plaintext at 10 dB ==")
|
||||
for name in ("kpa.csv", "kpa_learned.csv"):
|
||||
r = [x for x in rows(name) if float(x["snr_db"]) == 10.0]
|
||||
print(" %-16s N=2 %.4f N=8 %.4f N=64 %.4f"
|
||||
% (name, at(r, "n_frames", 2, "eve_ser"),
|
||||
at(r, "n_frames", 8, "eve_ser"),
|
||||
at(r, "n_frames", 64, "eve_ser")))
|
||||
|
||||
print("\n== Fig. 8, real token streams ==")
|
||||
s = rows("real_sec_ter.csv")
|
||||
l = rows("real_sec_ter_learned.csv")
|
||||
gaps = []
|
||||
for a, b in zip(s, l):
|
||||
for col in ("ter_eve", "ter_insider"):
|
||||
gaps.append(abs(float(a[col]) - float(b[col])))
|
||||
print(" %4s dB legit str %.5f lrn %.5f ratio %.3f"
|
||||
% (a["snr_db"], float(a["ter_legit"]), float(b["ter_legit"]),
|
||||
float(b["ter_legit"]) / float(a["ter_legit"])))
|
||||
print(" largest adversary gap between families %.2e" % max(gaps))
|
||||
for name in ("real_sec_stats.json", "real_sec_stats_learned.json"):
|
||||
if (DATA / name).exists():
|
||||
d = json.loads((DATA / name).read_text())
|
||||
print(" %-28s %s" % (name, {k: d[k] for k in list(d)[:6]}))
|
||||
|
||||
print("\n== Table IV, scheme comparison ==")
|
||||
for name in ("sec_compare.csv", "compare_learned.csv"):
|
||||
for r in rows(name):
|
||||
print(" %-20s %s" % (name, dict(r)))
|
||||
|
||||
print("\n== Table V, refresh ==")
|
||||
l = rows("refresh_learned.csv")
|
||||
print(" lrn legit %.5f to %.5f"
|
||||
% (min(float(r["legit_ser"]) for r in l),
|
||||
max(float(r["legit_ser"]) for r in l)))
|
||||
print(" lrn eve %.5f" % (sum(float(r["eve_ser"]) for r in l)
|
||||
/ len(l)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Rerun the four measurements the audit found mis-specified.
|
||||
|
||||
refresh the learned rows ran 8 blocks while the table says 24
|
||||
jamming the learned curve ran a 5 dB grid inside a 2 dB figure
|
||||
compare the learned row averaged four users inside a user-1 table
|
||||
enum the enumeration attacks ran on the unpenalized learned keys
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import exp_learned as E
|
||||
import check_family_enum as F
|
||||
|
||||
if __name__ == "__main__":
|
||||
for fn in (E.refresh, E.jamming, E.compare, F.run):
|
||||
print("=" * 60)
|
||||
print("stage", fn.__module__ + "." + fn.__name__, flush=True)
|
||||
fn()
|
||||
print("[done] audit reruns complete")
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Regenerate every learned-key artifact under the regularized loss.
|
||||
|
||||
learned_model now trains under the two penalties of Section V-C, so
|
||||
every *_learned file has to be rebuilt from it. sens runs before brute
|
||||
because brute re-reads the sensitivity sweep it produced.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import exp_learned as E
|
||||
import exp_full as F
|
||||
|
||||
# stage_N is Fig. 2's learned SNR sweep and lives in exp_full, so it is
|
||||
# named here rather than in exp_learned's own list
|
||||
STAGES = [F.stage_N, E.keylen, E.jamming, E.kpa, E.refresh, E.compare,
|
||||
E.sens, E.brute, E.real]
|
||||
|
||||
if __name__ == "__main__":
|
||||
only = sys.argv[1:]
|
||||
for fn in STAGES:
|
||||
if only and fn.__name__ not in only:
|
||||
continue
|
||||
print("=" * 60)
|
||||
print("stage", fn.__name__, flush=True)
|
||||
fn()
|
||||
print("[done] every learned artifact rebuilt")
|
||||
Reference in New Issue
Block a user