Keyed masking for secure multi-user semantic communication
Reproducibility package for the TIFS submission: transmit and receive core, security stages (eavesdropper, jamming, key families, attack difficulty, known-plaintext), real BERT token streams, closed-form verification, and the scripts that regenerate every figure and table from the released CSVs.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
out/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ki-Ho Lee, Hyun-Ho Choi, Jung-Ryun Lee
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Keyed Masking for Secure Multi-User Semantic Communication
|
||||
|
||||
Reproducibility package for the manuscript *Mask-as-Key Secure Multiple
|
||||
Access for Semantic Communications: Physical-Layer Encryption and
|
||||
Jamming Robustness*, submitted to the IEEE Transactions on Information
|
||||
Forensics and Security.
|
||||
|
||||
Every figure and table in the paper is regenerated from this
|
||||
repository. Experiment scripts write CSV files to `data/` and never
|
||||
draw; `replot_security.py` reads only `data/` and writes the figure PDFs
|
||||
to `fig/`; `make_tables.py` prints the LaTeX rows of the result tables.
|
||||
|
||||
## Idea
|
||||
|
||||
Multi-user semantic communication superposes several users on one frame
|
||||
and multiplies each user embedding by a distinct pattern so that the
|
||||
receivers can separate them. This code treats that pattern as a secret
|
||||
key. One keyed operation then encrypts each user against a receiver
|
||||
without the key, separates the users, and spreads a jammer that does not
|
||||
hold the key, at no extra bandwidth, power, or rate.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
code/
|
||||
sse_lib.py transmit and receive core, channel, training, OMA reference
|
||||
exp_full.py stages A-F: SNR sweep, key length, jamming, key families,
|
||||
scheme comparison, attack difficulty
|
||||
exp_kpa.py stage H: known-plaintext attack on the key
|
||||
exp_real_sec.py stage G: real BERT WordPiece token streams
|
||||
verify_math.py closed-form checks V1-V5 against Monte Carlo, PASS/FAIL
|
||||
replot_security.py every result figure, from data/ to fig/
|
||||
make_tables.py LaTeX rows of the two result tables, from data/
|
||||
feasibility_security.py early CPU-sized study, kept for the record
|
||||
data/ CSV results, one file per stage
|
||||
fig/ figure PDFs, regenerated by replot_security.py
|
||||
```
|
||||
|
||||
## Reproducing
|
||||
|
||||
Requires Python 3, PyTorch, NumPy, and Matplotlib. The real-token stage
|
||||
additionally needs `datasets` and `transformers`. A CUDA device is
|
||||
recommended; the code falls back to CPU. Under Windows install PyTorch
|
||||
in WSL, because the native Windows build does not load the CUDA
|
||||
libraries.
|
||||
|
||||
```bash
|
||||
python verify_math.py # closed-form verification, prints PASS/FAIL
|
||||
python exp_full.py # stages A-F
|
||||
python exp_kpa.py # known-plaintext attack
|
||||
python exp_real_sec.py # real token streams
|
||||
python replot_security.py # all figures from the CSVs
|
||||
python make_tables.py # LaTeX rows of the result tables
|
||||
```
|
||||
|
||||
Seeds are fixed: training 1, evaluation 777, attacker key guess
|
||||
20260813. Re-running reproduces the released CSV files.
|
||||
|
||||
## Figure and table map
|
||||
|
||||
| Artifact | Script | Data |
|
||||
|---|---|---|
|
||||
| Fig. 2 SER against SNR | `exp_full.stage_A` | `sec_snr.csv` |
|
||||
| Fig. 3 key length | `exp_full.stage_B` | `sec_keylen.csv` |
|
||||
| Fig. 4 jamming | `exp_full.stage_C` | `sec_jam.csv` |
|
||||
| Fig. 5 key sensitivity | `exp_full.stage_F` | `sec_sens.csv` |
|
||||
| Fig. 6 brute-force search | `exp_full.stage_F` | `sec_brute.csv` |
|
||||
| Fig. 7 known-plaintext attack | `exp_kpa` | `kpa.csv` |
|
||||
| Fig. 8 real token streams | `exp_real_sec` | `real_sec_ter.csv` |
|
||||
| Scheme comparison table | `exp_full.stage_E` | `sec_compare.csv` |
|
||||
| Key family table | `exp_full.stage_D` | `sec_maskfam.csv`, `sec_regjam.csv` |
|
||||
| Headline recovery table | `exp_real_sec` | `real_sec_stats.json` |
|
||||
|
||||
## Security scope
|
||||
|
||||
The analysis covers an adversary that observes transmitted frames. The
|
||||
masking is linear, so an adversary that also learns the indices some
|
||||
frames carried recovers the key from a few frames, which `exp_kpa.py`
|
||||
measures. The key must therefore be refreshed per coherence block from
|
||||
a shared seed, as the paper states. This repository implements the
|
||||
measurement of that limit, not a key-refresh layer.
|
||||
|
||||
## License
|
||||
|
||||
MIT for the code. The AG News data and the language-model tokenizer are
|
||||
obtained from their own sources under their own terms.
|
||||
@@ -0,0 +1,523 @@
|
||||
"""Full-scale security evaluation for paper 11 (run under WSL CUDA).
|
||||
|
||||
Reuses the SSE transmit/receive core from sse_lib.py and adds an
|
||||
eavesdropper receiver, a jammer channel, and structured mask families.
|
||||
Main configuration d=64, P=4, Vu=16 (V=Vu^P=65,536), U=4 users, matching
|
||||
the language-model token vocabulary scale.
|
||||
|
||||
Stages (each writes a CSV to ../data; figures come from replot_security.py
|
||||
and the two result tables from make_tables.py):
|
||||
A security vs SNR -> sec_snr.csv (Fig. 2)
|
||||
B key length -> sec_keylen.csv (Fig. 3)
|
||||
C jamming vs JSR -> sec_jam.csv (Fig. 4)
|
||||
D mask families -> sec_maskfam.csv (key-family table)
|
||||
E scheme comparison -> sec_compare.csv (comparison table)
|
||||
F attack difficulty -> sec_sens.csv, sec_brute.csv (Figs. 5-6)
|
||||
|
||||
Experiment scripts write CSV only, never draw. Fixed seeds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
import sse_lib as L
|
||||
from sse_lib import (SSE, rayleigh_gain, snr_to_sigma2, write_csv, set_seed,
|
||||
eval_ser_sse, oma_ser, DATA, DEVICE)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# eavesdropper: correlate the transmitted (true-mask) frame with a
|
||||
# substitute mask the eavesdropper does not truly hold.
|
||||
# ----------------------------------------------------------------------
|
||||
@torch.no_grad()
|
||||
def eval_ser_eve(model: SSE, eve_masks: torch.Tensor, snr_list,
|
||||
frames: int, chunk: int = 100_000, seed: int = 777):
|
||||
model.eval().to(DEVICE)
|
||||
Bn = model.unit_codebook()
|
||||
true_m = model.masks()
|
||||
eve_masks = eve_masks.to(DEVICE)
|
||||
c = model.c
|
||||
out = []
|
||||
for snr_db in snr_list:
|
||||
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
|
||||
err = tot = 0
|
||||
for n0 in range(0, frames, chunk):
|
||||
n = min(chunk, frames - n0)
|
||||
digits = torch.randint(model.vu, (n, model.users, model.P),
|
||||
generator=g).to(DEVICE)
|
||||
e = Bn[digits] / math.sqrt(model.P)
|
||||
y = (e * true_m[None, :, None, :]).sum(dim=1) / c
|
||||
h = rayleigh_gain((n, model.users), device=DEVICE)
|
||||
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
|
||||
noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE)
|
||||
y_rx = h[:, :, None, None] * y[:, None] + sigma * noise
|
||||
r = y_rx / h[:, :, None, None].clamp_min(1e-6)
|
||||
cand = Bn[None, :, :] * eve_masks[:, None, :]
|
||||
scores = torch.einsum("nupl,uvl->nupv", r, cand)
|
||||
wrong = (scores.argmax(-1) != digits).any(dim=2)
|
||||
err += int(wrong.sum()); tot += n * model.users
|
||||
out.append(err / tot)
|
||||
return out
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def eval_ser_jam(model: SSE, snr_db, jsr_db_list, frames: int,
|
||||
chunk: int = 100_000, seed: int = 777, mode: str = "blind",
|
||||
target: int = 0):
|
||||
"""Returns the target-user SER (user `target`, the user a mask-matched
|
||||
jammer aims at). The mask-matched jammer aligns with the target key,
|
||||
which a mask-blind jammer cannot do. Reporting the target-user SER,
|
||||
rather than the user average, isolates how efficiently each jammer can
|
||||
degrade a chosen victim (Proposition 2)."""
|
||||
model.eval().to(DEVICE)
|
||||
Bn = model.unit_codebook()
|
||||
true_m = model.masks()
|
||||
c = model.c
|
||||
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
|
||||
# matched jammer aligns with the target user's masked codeword mean
|
||||
# direction (needs that user's secret key)
|
||||
w_fixed = (Bn[target][None, :] * true_m[target][None, :]).repeat(model.P, 1)
|
||||
w_fixed = w_fixed / w_fixed.norm()
|
||||
out = []
|
||||
for jsr_db in jsr_db_list:
|
||||
jsr = 10.0 ** (jsr_db / 10.0)
|
||||
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * jsr_db))
|
||||
err = tot = 0
|
||||
for n0 in range(0, frames, chunk):
|
||||
n = min(chunk, frames - n0)
|
||||
digits = torch.randint(model.vu, (n, model.users, model.P),
|
||||
generator=g).to(DEVICE)
|
||||
e = Bn[digits] / math.sqrt(model.P)
|
||||
y = (e * true_m[None, :, None, :]).sum(dim=1) / c
|
||||
h = rayleigh_gain((n, model.users), device=DEVICE)
|
||||
hJ = rayleigh_gain((n,), device=DEVICE)
|
||||
if mode == "matched":
|
||||
w = w_fixed[None].expand(n, model.P, model.L)
|
||||
else:
|
||||
w = torch.randn(n, model.P, model.L, device=DEVICE)
|
||||
w = w / w.reshape(n, -1).norm(dim=1)[:, None, None].clamp_min(1e-8)
|
||||
jam = (hJ * math.sqrt(jsr))[:, None, None] * w
|
||||
noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE)
|
||||
y_rx = h[:, :, None, None] * y[:, None] + jam[:, None] + sigma * noise
|
||||
r = y_rx / h[:, :, None, None].clamp_min(1e-6)
|
||||
cand = Bn[None, :, :] * true_m[:, None, :]
|
||||
scores = torch.einsum("nupl,uvl->nupv", r, cand)
|
||||
wrong = (scores.argmax(-1) != digits).any(dim=2) # (n,U)
|
||||
err += int(wrong[:, target].sum()); tot += n
|
||||
out.append(err / tot)
|
||||
return out
|
||||
|
||||
|
||||
def hadamard(n: int) -> np.ndarray:
|
||||
"""Sylvester construction, n a power of two."""
|
||||
H = np.array([[1.0]])
|
||||
while H.shape[0] < n:
|
||||
H = np.block([[H, H], [H, -H]])
|
||||
return H
|
||||
|
||||
|
||||
def mean_abs_xcorr(masks: torch.Tensor) -> float:
|
||||
m = masks / masks.norm(dim=1, keepdim=True).clamp_min(1e-8)
|
||||
G = (m @ m.T).abs()
|
||||
U = m.shape[0]
|
||||
off = G[~torch.eye(U, dtype=torch.bool, device=G.device)]
|
||||
return float(off.mean())
|
||||
|
||||
|
||||
def random_mask(U, Lp):
|
||||
W = torch.randn(U, Lp)
|
||||
return W / W.norm(dim=1, keepdim=True) * math.sqrt(Lp)
|
||||
|
||||
|
||||
def eve_wrong_mask(U, Lp, seed):
|
||||
g = torch.Generator().manual_seed(seed)
|
||||
W = torch.randn(U, Lp, generator=g)
|
||||
return W / W.norm(dim=1, keepdim=True) * math.sqrt(Lp)
|
||||
|
||||
|
||||
def get_model(P=4, vu=16, d=64, U=4, iters=4000, seed=1, freeze_W=None, tag=""):
|
||||
"""Train an SSE model, optionally with fixed (frozen) masks."""
|
||||
set_seed(seed)
|
||||
m = SSE(P=P, vu=vu, d=d, users=U).to(DEVICE)
|
||||
if freeze_W is not None:
|
||||
with torch.no_grad():
|
||||
m.W.copy_(freeze_W.to(DEVICE))
|
||||
m.W.requires_grad_(False)
|
||||
L.train_sse(m, iters=iters, batch=256, lr=3e-3, seed=seed)
|
||||
m.calibrate_power()
|
||||
return m
|
||||
|
||||
|
||||
def stage_A():
|
||||
print("[A] security vs SNR (V=65536) ...")
|
||||
m = get_model(iters=4000)
|
||||
snr = [0.0, 4.0, 8.0, 12.0, 16.0, 20.0]
|
||||
frames = 800_000
|
||||
legit = eval_ser_sse(m, snr, frames=frames)
|
||||
ew = eve_wrong_mask(m.users, m.L, seed=20260813).to(DEVICE)
|
||||
eve_w = eval_ser_eve(m, ew, snr, frames=frames)
|
||||
eve_n = eval_ser_eve(m, torch.ones(m.users, m.L), snr, frames=frames)
|
||||
# conventional public-mask scheme: the eavesdropper holds the same
|
||||
# (public) masks and decodes exactly like a legitimate user
|
||||
eve_p = eval_ser_eve(m, m.masks().detach().cpu(), snr, frames=frames)
|
||||
oma = oma_ser(snr, bits=int(math.log2(m.V)))
|
||||
chance = 1.0 - (1.0 / m.vu) ** m.P
|
||||
write_csv(DATA / "sec_snr.csv",
|
||||
["snr_db", "legit", "eve_wrong", "eve_none", "eve_public",
|
||||
"oma", "chance"],
|
||||
[(s, legit[i], eve_w[i], eve_n[i], eve_p[i], oma[i], chance)
|
||||
for i, s in enumerate(snr)])
|
||||
print(" legit:", [f"{v:.2e}" for v in legit])
|
||||
print(" eve :", [f"{v:.3f}" for v in eve_w])
|
||||
|
||||
|
||||
def train_sse_reg(m: SSE, iters=4000, batch=256, lr=3e-3, seed=1,
|
||||
lam_orth=1.0, lam_flat=0.1):
|
||||
"""Regularized key learning for improved spreading and de-spreading.
|
||||
Adds to the digit-wise cross entropy (i) an orthogonality penalty on
|
||||
the off-diagonal key Gram entries, which reduces cross-user
|
||||
interference and residual leakage, and (ii) a constant-modulus
|
||||
penalty that flattens the key spectrum, which maximizes the spreading
|
||||
of a mask-blind jammer (Proposition 2: the jammer concentration on
|
||||
candidate i is sum_k w_k^2 e_{i,k}^2 weighted through the key, and a
|
||||
flat key removes any low-energy entries a jammer could exploit)."""
|
||||
set_seed(seed)
|
||||
m.to(DEVICE)
|
||||
opt = torch.optim.Adam(m.parameters(), lr=lr)
|
||||
ce = torch.nn.CrossEntropyLoss()
|
||||
for it in range(1, iters + 1):
|
||||
digits = torch.randint(m.vu, (batch, m.users, m.P), device=DEVICE)
|
||||
snr = torch.empty(batch).uniform_(0.0, 20.0)
|
||||
m.calibrate_power(8192)
|
||||
scores = m(digits, snr) * m.logit_scale.exp()
|
||||
loss = ce(scores.reshape(-1, m.vu), digits.reshape(-1))
|
||||
mk = m.masks()
|
||||
G = (mk @ mk.T) / m.L
|
||||
off = G - torch.eye(m.users, device=G.device)
|
||||
loss = loss + lam_orth * off.pow(2).sum()
|
||||
loss = loss + lam_flat * (mk.pow(2) - 1.0).pow(2).mean()
|
||||
opt.zero_grad(); loss.backward(); opt.step()
|
||||
m.calibrate_power()
|
||||
return m
|
||||
|
||||
|
||||
def get_model_reg(P=4, vu=16, d=64, U=4, iters=4000, seed=1):
|
||||
set_seed(seed)
|
||||
m = SSE(P=P, vu=vu, d=d, users=U).to(DEVICE)
|
||||
train_sse_reg(m, iters=iters, seed=seed)
|
||||
return m
|
||||
|
||||
|
||||
def stage_B():
|
||||
print("[B] key length (dense grid so the curve is smooth) ...")
|
||||
oma10 = oma_ser([10.0], bits=16)[0]
|
||||
rows = []
|
||||
for d in [16, 24, 32, 48, 64, 96, 128, 192, 256]:
|
||||
m = get_model(d=d, iters=4000)
|
||||
lg = eval_ser_sse(m, [10.0], frames=500_000)[0]
|
||||
ew = eve_wrong_mask(m.users, m.L, seed=20260813).to(DEVICE)
|
||||
ev = eval_ser_eve(m, ew, [10.0], frames=500_000)[0]
|
||||
xc = mean_abs_xcorr(m.masks().detach())
|
||||
rows.append((m.L, d, lg, ev, xc, oma10))
|
||||
print(f" L={m.L:4d} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f}")
|
||||
write_csv(DATA / "sec_keylen.csv",
|
||||
["L", "d", "legit_ser", "eve_ser", "mask_xcorr", "oma"], rows)
|
||||
|
||||
|
||||
def stage_C():
|
||||
print("[C] jamming vs JSR ...")
|
||||
m = get_model(iters=4000)
|
||||
jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0]
|
||||
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)
|
||||
# target-user SER with no jammer, for the reference line
|
||||
nojam = eval_ser_jam(m, 10.0, [-40.0], frames=500_000, mode="blind",
|
||||
target=0)[0]
|
||||
write_csv(DATA / "sec_jam.csv",
|
||||
["jsr_db", "blind", "matched", "nojam"],
|
||||
[(j, blind[i], matched[i], nojam) for i, j in enumerate(jsr)])
|
||||
print(f" target no-jam={nojam:.2e}")
|
||||
print(" blind :", [f"{v:.3f}" for v in blind])
|
||||
print(" matched:", [f"{v:.3f}" for v in matched])
|
||||
|
||||
|
||||
def stage_D():
|
||||
print("[D] mask families ...")
|
||||
P, vu, d, U = 4, 16, 64, 4
|
||||
Lp = d // P
|
||||
fams = {}
|
||||
# random fixed masks
|
||||
set_seed(7); fams["random"] = random_mask(U, Lp)
|
||||
# Walsh-Hadamard rows (orthogonal)
|
||||
Hd = torch.tensor(hadamard(Lp)[:U], dtype=torch.float32) # ||row||=sqrt(Lp)
|
||||
fams["hadamard"] = Hd
|
||||
rows = []
|
||||
for name, W in fams.items():
|
||||
m = get_model(P=P, vu=vu, d=d, U=U, iters=4000, freeze_W=W)
|
||||
lg = eval_ser_sse(m, [10.0], frames=500_000)[0]
|
||||
ew = eve_wrong_mask(U, Lp, seed=20260813).to(DEVICE)
|
||||
ev = eval_ser_eve(m, ew, [10.0], frames=500_000)[0]
|
||||
xc = mean_abs_xcorr(m.masks().detach())
|
||||
rows.append((name, lg, ev, xc))
|
||||
print(f" {name:9s} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f}")
|
||||
# learned masks (plain cross entropy)
|
||||
m = get_model(P=P, vu=vu, d=d, U=U, iters=4000)
|
||||
lg = eval_ser_sse(m, [10.0], frames=500_000)[0]
|
||||
ew = eve_wrong_mask(U, Lp, seed=20260813).to(DEVICE)
|
||||
ev = eval_ser_eve(m, ew, [10.0], frames=500_000)[0]
|
||||
xc = mean_abs_xcorr(m.masks().detach())
|
||||
rows.append(("learned", lg, ev, xc))
|
||||
print(f" {'learned':9s} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f}")
|
||||
# regularized key learning (orthogonality + constant modulus)
|
||||
mr = get_model_reg(P=P, vu=vu, d=d, U=U, iters=4000)
|
||||
lgr = eval_ser_sse(mr, [10.0], frames=500_000)[0]
|
||||
evr = eval_ser_eve(mr, ew, [10.0], frames=500_000)[0]
|
||||
xcr = mean_abs_xcorr(mr.masks().detach())
|
||||
rows.append(("learned_reg", lgr, evr, xcr))
|
||||
print(f" {'learn_reg':9s} legit={lgr:.2e} eve={evr:.3f} xcorr={xcr:.4f}")
|
||||
# jamming robustness of plain vs regularized keys (blind jammer)
|
||||
jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0]
|
||||
jb_plain = eval_ser_jam(m, 10.0, jsr, frames=300_000, mode="blind")
|
||||
jb_reg = eval_ser_jam(mr, 10.0, jsr, frames=300_000, mode="blind")
|
||||
write_csv(DATA / "sec_regjam.csv",
|
||||
["jsr_db", "plain", "regularized"],
|
||||
[(j, jb_plain[i], jb_reg[i]) for i, j in enumerate(jsr)])
|
||||
write_csv(DATA / "sec_maskfam.csv",
|
||||
["family", "legit_ser", "eve_ser", "mask_xcorr"], rows)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def eval_scheme(model: SSE, snr_db, frames, *, rx_masks=None, perms=None,
|
||||
jam_w=None, jsr_db=None, target=0, chunk=100_000, seed=777,
|
||||
decode_user=0):
|
||||
"""Generic evaluator for the comparison schemes.
|
||||
rx_masks: masks used at the decoding receiver (None = true masks).
|
||||
perms: (U,d-index) per-user secret permutations applied at tx to
|
||||
x_u; the decoder for `decode_user` inverse-permutes first.
|
||||
rx side without the permutation just decodes raw.
|
||||
jam_w: None or 'matched'/'blind' jammer aimed at `target`.
|
||||
Returns SER of `decode_user` (frame error over its P digits)."""
|
||||
model.eval().to(DEVICE)
|
||||
Bn = model.unit_codebook()
|
||||
true_m = model.masks()
|
||||
c = model.c
|
||||
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
|
||||
d = model.P * model.L
|
||||
if perms is not None:
|
||||
inv = torch.argsort(perms, dim=1)
|
||||
if jam_w == "matched":
|
||||
wf = (Bn[target][None, :] * true_m[target][None, :]).repeat(model.P, 1)
|
||||
if perms is not None:
|
||||
wfl = wf.reshape(-1)[perms[target]]
|
||||
wf = wfl.reshape(model.P, model.L)
|
||||
wf = wf / wf.norm()
|
||||
jsr = 10.0 ** (jsr_db / 10.0) if jsr_db is not None else 0.0
|
||||
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
|
||||
err = tot = 0
|
||||
for n0 in range(0, frames, chunk):
|
||||
n = min(chunk, frames - n0)
|
||||
digits = torch.randint(model.vu, (n, model.users, model.P),
|
||||
generator=g).to(DEVICE)
|
||||
e = Bn[digits] / math.sqrt(model.P)
|
||||
x = e * true_m[None, :, None, :] # (n,U,P,L)
|
||||
if perms is not None:
|
||||
xf = x.reshape(n, model.users, d)
|
||||
xf = torch.stack([xf[:, u][:, perms[u]] for u in range(model.users)], 1)
|
||||
x = xf.reshape(n, model.users, model.P, model.L)
|
||||
y = x.sum(dim=1) / c # (n,P,L)
|
||||
h = rayleigh_gain((n,), device=DEVICE) # decode_user channel
|
||||
y_rx = h[:, None, None] * y # (n,P,L)
|
||||
if jam_w is not None:
|
||||
hJ = rayleigh_gain((n,), device=DEVICE)
|
||||
if jam_w == "matched":
|
||||
w = wf[None].expand(n, model.P, model.L)
|
||||
else:
|
||||
w = torch.randn(n, model.P, model.L, device=DEVICE)
|
||||
w = w / w.reshape(n, -1).norm(dim=1)[:, None, None].clamp_min(1e-8)
|
||||
y_rx = y_rx + (hJ * math.sqrt(jsr))[:, None, None] * w
|
||||
y_rx = y_rx + sigma * torch.randn(n, model.P, model.L, device=DEVICE)
|
||||
r = y_rx / h[:, None, None].clamp_min(1e-6)
|
||||
if perms is not None:
|
||||
rf = r.reshape(n, d)[:, inv[decode_user]]
|
||||
r = rf.reshape(n, model.P, model.L)
|
||||
m_rx = true_m if rx_masks is None else rx_masks.to(DEVICE)
|
||||
cand = Bn * m_rx[decode_user][None, :] # (Vu,L)
|
||||
scores = torch.einsum("npl,vl->npv", r, cand)
|
||||
wrong = (scores.argmax(-1) != digits[:, decode_user]).any(dim=1)
|
||||
err += int(wrong.sum()); tot += n
|
||||
return err / tot
|
||||
|
||||
|
||||
def stage_E():
|
||||
"""Comparison across five schemes at 10 dB, V=65,536, user-0 metrics.
|
||||
Columns: legitimate SER; outsider-eavesdropper SER; insider SER (a
|
||||
curious legitimate user of the SAME system decoding user 0 with its
|
||||
own credentials); target-user SER under the strongest jammer the
|
||||
attacker can BUILD from public knowledge at JSR 0 dB (matched if the
|
||||
masks are public, blind if the PHY structure is secret)."""
|
||||
print("[E] scheme comparison ...")
|
||||
m = get_model(iters=4000)
|
||||
F = 400_000
|
||||
d = m.P * m.L
|
||||
set_seed(20260813)
|
||||
ew = eve_wrong_mask(m.users, m.L, seed=20260813)
|
||||
# shuffling-style multi-user adaptation: one GLOBAL secret permutation
|
||||
# shared by all users (per-user permutations break the trained
|
||||
# multi-user separation, so the shared key is the fair extension)
|
||||
gp = torch.Generator().manual_seed(11)
|
||||
gperm = torch.randperm(d, generator=gp)
|
||||
perms = gperm[None].repeat(m.users, 1)
|
||||
|
||||
chance = 1.0 - (1.0 / m.vu) ** m.P
|
||||
insider_masks = torch.roll(m.masks().detach().cpu(), 1, 0) # user 1's key
|
||||
|
||||
rows = []
|
||||
# S1 proposed keyed masking: per-user secret masks
|
||||
lg = eval_scheme(m, 10.0, F)
|
||||
ev = eval_scheme(m, 10.0, F, rx_masks=ew)
|
||||
ins = eval_scheme(m, 10.0, F, rx_masks=insider_masks)
|
||||
jm = eval_scheme(m, 10.0, F, jam_w="blind", jsr_db=0.0)
|
||||
rows.append(("proposed", lg, ev, ins, jm))
|
||||
# S2 public-mask superposition (no key): everyone decodes, attacker
|
||||
# builds the matched jammer
|
||||
jm2 = eval_scheme(m, 10.0, F, jam_w="matched", jsr_db=0.0)
|
||||
rows.append(("public_mask", lg, lg, lg, jm2))
|
||||
# S3 global permutation key over public masks (shuffling-style): the
|
||||
# outsider lacks the permutation, but every insider holds it and the
|
||||
# masks are public, so insiders decode each other
|
||||
lg3 = eval_scheme(m, 10.0, F, perms=perms)
|
||||
ev3 = eval_scheme_permuted_eve(m, 10.0, F, perms)
|
||||
jm3 = eval_scheme(m, 10.0, F, perms=perms, jam_w="blind", jsr_db=0.0)
|
||||
rows.append(("perm_key", lg3, ev3, lg3, jm3))
|
||||
# S4 per-user index cipher (one-time pad on the digits) over public
|
||||
# masks: content protected from outsiders and insiders, but the PHY
|
||||
# is public so the matched jammer remains buildable
|
||||
rows.append(("index_cipher", lg, chance, chance, jm2))
|
||||
# S5 OMA digital, no encryption: open to everyone
|
||||
from sse_lib import oma_ser
|
||||
lg5 = oma_ser([10.0], bits=int(math.log2(m.V)))[0]
|
||||
rows.append(("oma_plain", lg5, lg5, lg5, float("nan")))
|
||||
|
||||
write_csv(DATA / "sec_compare.csv",
|
||||
["scheme", "legit_ser", "eve_out", "eve_in", "jam0_ser"], rows)
|
||||
for r in rows:
|
||||
print(" ", r)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def eval_scheme_permuted_eve(model: SSE, snr_db, frames, perms,
|
||||
chunk=100_000, seed=777):
|
||||
"""Eve for S3: sees the per-user permuted tx, holds the PUBLIC masks
|
||||
but not the permutation, decodes user 0 raw."""
|
||||
model.eval().to(DEVICE)
|
||||
Bn = model.unit_codebook()
|
||||
true_m = model.masks()
|
||||
c = model.c
|
||||
d = model.P * model.L
|
||||
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
|
||||
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
|
||||
err = tot = 0
|
||||
for n0 in range(0, frames, chunk):
|
||||
n = min(chunk, frames - n0)
|
||||
digits = torch.randint(model.vu, (n, model.users, model.P),
|
||||
generator=g).to(DEVICE)
|
||||
e = Bn[digits] / math.sqrt(model.P)
|
||||
x = e * true_m[None, :, None, :]
|
||||
xf = x.reshape(n, model.users, d)
|
||||
xf = torch.stack([xf[:, u][:, perms[u]] for u in range(model.users)], 1)
|
||||
y = xf.reshape(n, model.users, model.P, model.L).sum(dim=1) / c
|
||||
h = rayleigh_gain((n,), device=DEVICE)
|
||||
y_rx = h[:, None, None] * y + sigma * torch.randn(
|
||||
n, model.P, model.L, device=DEVICE)
|
||||
r = y_rx / h[:, None, None].clamp_min(1e-6)
|
||||
cand = Bn * true_m[0][None, :]
|
||||
scores = torch.einsum("npl,vl->npv", r, cand)
|
||||
wrong = (scores.argmax(-1) != digits[:, 0]).any(dim=1)
|
||||
err += int(wrong.sum()); tot += n
|
||||
return err / tot
|
||||
|
||||
|
||||
def correlated_masks(true_m: torch.Tensor, rho: float, gen: torch.Generator):
|
||||
"""Substitute masks with prescribed normalized correlation rho to the
|
||||
true keys: mtil = rho*m + sqrt(1-rho^2)*m_perp, ||mtil|| = ||m||."""
|
||||
U, Lp = true_m.shape
|
||||
out = torch.empty_like(true_m)
|
||||
for u in range(U):
|
||||
m = true_m[u]
|
||||
p = torch.randn(Lp, generator=gen)
|
||||
p = p - (p @ m) / (m @ m) * m
|
||||
p = p / p.norm() * m.norm()
|
||||
out[u] = rho * m + math.sqrt(max(0.0, 1 - rho * rho)) * p
|
||||
return out
|
||||
|
||||
|
||||
def stage_F():
|
||||
"""Attack difficulty in the style of standard security evaluations.
|
||||
(i) Key sensitivity: Eve SER against the correlation rho between her
|
||||
guess and the true key (avalanche-style curve).
|
||||
(ii) Brute-force key search: expected Eve SER against the number of
|
||||
random key guesses K, where for each trial the attacker keeps the
|
||||
guess with the LARGEST correlation to the true key (a genie-aided
|
||||
upper bound on any selection rule). The best-guess correlation
|
||||
rho_max(K, L) is sampled by Monte Carlo and mapped through the
|
||||
measured sensitivity curve of (i)."""
|
||||
print("[F] attack difficulty ...")
|
||||
m = get_model(iters=4000)
|
||||
F = 200_000
|
||||
true_m = m.masks().detach().cpu()
|
||||
gen = torch.Generator().manual_seed(31)
|
||||
|
||||
# (i) sensitivity curve, densest where the curve falls steeply
|
||||
rhos = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.65, 0.7, 0.75,
|
||||
0.8, 0.84, 0.88, 0.90, 0.92, 0.94, 0.96, 0.97, 0.98,
|
||||
0.99, 0.995, 1.0]
|
||||
sens = []
|
||||
for rho in rhos:
|
||||
mt = correlated_masks(true_m, rho, gen)
|
||||
ser = eval_ser_eve(m, mt, [10.0], frames=F)[0]
|
||||
sens.append((rho, ser))
|
||||
print(f" rho={rho:.2f} eve_ser={ser:.4f}")
|
||||
write_csv(DATA / "sec_sens.csv", ["rho", "eve_ser"], sens)
|
||||
|
||||
# (ii) brute-force: sample rho_max(K, L) and interpolate SER(rho)
|
||||
import numpy as np
|
||||
r_arr = np.array([r for r, _ in sens])
|
||||
s_arr = np.array([s for _, s in sens])
|
||||
|
||||
def ser_of_rho(r):
|
||||
return float(np.interp(abs(r), r_arr, s_arr))
|
||||
|
||||
ks = [1, 10, 100, 1_000, 10_000, 100_000, 1_000_000]
|
||||
rows = []
|
||||
rng = np.random.default_rng(2026)
|
||||
for Lp in [8, 16, 32, 64]:
|
||||
for K in ks:
|
||||
trials = 400
|
||||
# rho of a random unit guess vs a fixed key in R^L is the
|
||||
# first coordinate of a random unit vector; sample K per trial
|
||||
best = np.empty(trials)
|
||||
for t in range(trials):
|
||||
g = rng.standard_normal((K, Lp))
|
||||
g /= np.linalg.norm(g, axis=1, keepdims=True)
|
||||
best[t] = np.abs(g[:, 0]).max()
|
||||
ser_est = float(np.mean([ser_of_rho(b) for b in best]))
|
||||
rows.append((Lp, K, float(best.mean()), ser_est))
|
||||
print(f" L={Lp} done")
|
||||
write_csv(DATA / "sec_brute.csv",
|
||||
["L", "K", "best_rho", "eve_ser"], rows)
|
||||
|
||||
|
||||
def main():
|
||||
print(f"device={DEVICE}")
|
||||
stage_A()
|
||||
stage_B()
|
||||
stage_C()
|
||||
stage_D()
|
||||
stage_E()
|
||||
stage_F()
|
||||
print("[done] full-scale security CSVs in", DATA)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
"""Stage H: known-plaintext attack on the keyed masking.
|
||||
|
||||
The masking is linear in the keys, so an attacker who knows the indices
|
||||
carried by some frames can write one linear equation per dimension per
|
||||
frame and solve for the keys by least squares. This script measures how
|
||||
much known plaintext the attacker needs before the recovered key is good
|
||||
enough to decode, and how receiver noise slows that recovery down.
|
||||
|
||||
Per dimension k the observation of frame n is
|
||||
y_k(n) = (1/c) sum_u e_{s_u(n),k} m_{u,k} + noise,
|
||||
so stacking N frames gives A m_k = y_k with A(n,u) = e_{s_u(n),k}/c, an
|
||||
N-by-U system that is solvable once N >= U in the noiseless case. The
|
||||
attacker solves it per dimension, then correlates the estimate with the
|
||||
true key and runs the correlation receiver with the estimated key.
|
||||
|
||||
Outputs:
|
||||
kpa.csv : key correlation and eavesdropper SER against the number of
|
||||
known-plaintext frames, at several SNRs
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sse_lib import (DATA, DEVICE, SSE, rayleigh_gain, snr_to_sigma2,
|
||||
set_seed, write_csv, eval_ser_sse)
|
||||
from exp_full import get_model, eval_ser_eve
|
||||
|
||||
SNRS = [0.0, 10.0, 20.0]
|
||||
NFRAMES = [1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 24, 32, 48, 64]
|
||||
SEED = 4242
|
||||
EVAL_FRAMES = 50_000
|
||||
# The spread across independent key-recovery attempts dominates the
|
||||
# spread across channel realizations within one attempt, so the curve is
|
||||
# smoothed by drawing many attempts rather than by lengthening each one.
|
||||
TRIALS = 40
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def collect_known_plaintext(model: SSE, n_frames: int, snr_db: float,
|
||||
gen: torch.Generator):
|
||||
"""Return (digits, raw observations, channel gains) for an attacker
|
||||
that knows the transmitted indices.
|
||||
|
||||
The raw observation is returned rather than an equalized one. A
|
||||
maximum-likelihood attacker keeps the channel gain in the design
|
||||
matrix instead of dividing by it, which weights every frame by its
|
||||
own quality and is the strongest use of the collected material. It
|
||||
also avoids the numerical blow-up that equalizing a deep fade would
|
||||
cause.
|
||||
"""
|
||||
digits = torch.randint(model.vu, (n_frames, model.users, model.P),
|
||||
generator=gen).to(DEVICE)
|
||||
Bn = model.unit_codebook()
|
||||
m = model.masks()
|
||||
e = Bn[digits] / math.sqrt(model.P)
|
||||
y = (e * m[None, :, None, :]).sum(dim=1) / model.c # (N,P,L)
|
||||
h = rayleigh_gain((n_frames,), device=DEVICE)
|
||||
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
|
||||
noise = torch.randn(n_frames, model.P, model.L, device=DEVICE)
|
||||
obs = h[:, None, None] * y + sigma * noise
|
||||
return digits, obs, h
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def solve_keys(model: SSE, digits, obs, h):
|
||||
"""Maximum-likelihood key estimate from known plaintext.
|
||||
|
||||
Each period of each frame is an independent observation of the same
|
||||
per-period key, so the P periods multiply the effective number of
|
||||
equations. For entry l the system is A x = b with
|
||||
A[(n,p), u] = h(n) e_{digit(n,u,p), l} / (c sqrt(P)) and b the raw
|
||||
observation, so a frame in a deep fade contributes a small row on
|
||||
both sides and is downweighted rather than amplified.
|
||||
"""
|
||||
Bn = model.unit_codebook() # (Vu, L)
|
||||
N, U, P = digits.shape
|
||||
L = model.L
|
||||
c = float(model.c)
|
||||
est = torch.zeros(U, L, device=DEVICE)
|
||||
for l in range(L):
|
||||
# design matrix over all (frame, period) pairs
|
||||
A = Bn[digits, l] / (c * math.sqrt(P)) # (N,U,P)
|
||||
A = A * h[:, None, None]
|
||||
A = A.permute(0, 2, 1).reshape(N * P, U) # (N*P, U)
|
||||
b = obs[:, :, l].reshape(N * P, 1) # (N*P, 1)
|
||||
# A pseudo-inverse with an absolute tolerance is used instead of
|
||||
# a least-squares driver. Training can leave a codebook entry
|
||||
# numerically dead, with every codeword value below the smallest
|
||||
# normal float. That entry carries no information about the
|
||||
# digit, and inverting its system would amplify noise without
|
||||
# bound, so the absolute tolerance discards it and the estimate
|
||||
# for that entry stays at zero, which is what a careful attacker
|
||||
# would do.
|
||||
sol = torch.linalg.pinv(A.double(), atol=1e-12, rtol=0.0) @ b.double()
|
||||
est[:, l] = sol[:, 0].float()
|
||||
est = torch.nan_to_num(est)
|
||||
# normalize to the key norm convention
|
||||
est = est / est.norm(dim=1, keepdim=True).clamp_min(1e-9) * math.sqrt(L)
|
||||
return est
|
||||
|
||||
|
||||
def key_correlation(est: torch.Tensor, true: torch.Tensor) -> float:
|
||||
"""Mean absolute normalized correlation over the users."""
|
||||
a = est / est.norm(dim=1, keepdim=True).clamp_min(1e-9)
|
||||
b = true / true.norm(dim=1, keepdim=True).clamp_min(1e-9)
|
||||
return float((a * b).sum(dim=1).abs().mean())
|
||||
|
||||
|
||||
def main():
|
||||
set_seed(SEED)
|
||||
model = get_model(iters=4000)
|
||||
model.eval()
|
||||
true_m = model.masks().detach()
|
||||
legit = eval_ser_sse(model, [10.0], frames=200_000)[0]
|
||||
print(f"[kpa] legitimate SER at 10 dB = {legit:.4g}, U={model.users}, "
|
||||
f"L={model.L}")
|
||||
|
||||
# Nested known-plaintext sets with common random numbers. Within a
|
||||
# trial the attacker collects one pool of frames and the N-frame
|
||||
# estimate uses the first N of them, so more material can only help,
|
||||
# exactly as an attacker accumulating traffic would experience. The
|
||||
# evaluation noise is also shared across N within a trial. Both
|
||||
# choices remove the between-point variance that would otherwise make
|
||||
# the averaged curve jagged, without changing what is being measured.
|
||||
nmax = max(NFRAMES)
|
||||
rows = []
|
||||
for snr in SNRS:
|
||||
acc = {n: [[], []] for n in NFRAMES}
|
||||
for t in range(TRIALS):
|
||||
gen = torch.Generator(device="cpu").manual_seed(
|
||||
SEED + int(snr) + 1000 * t)
|
||||
digits, obs, h = collect_known_plaintext(model, nmax, snr, gen)
|
||||
eval_seed = 777 + 31 * t + int(snr)
|
||||
for n in NFRAMES:
|
||||
est = solve_keys(model, digits[:n], obs[:n], h[:n])
|
||||
acc[n][0].append(key_correlation(est, true_m))
|
||||
acc[n][1].append(eval_ser_eve(model, est.cpu(), [10.0],
|
||||
frames=EVAL_FRAMES,
|
||||
seed=eval_seed)[0])
|
||||
for n in NFRAMES:
|
||||
ks, ss = acc[n]
|
||||
kappa = sum(ks) / len(ks)
|
||||
ser = sum(ss) / len(ss)
|
||||
rows.append((snr, n, kappa, ser))
|
||||
print(f" snr={snr:4.1f} N={n:5d} kappa={kappa:.4f} "
|
||||
f"eve_ser={ser:.4f}")
|
||||
write_csv(DATA / "kpa.csv",
|
||||
["snr_db", "n_frames", "kappa", "eve_ser"], rows)
|
||||
print("[done] kpa.csv")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Stage G: security on real language-model token streams.
|
||||
|
||||
AG News test headlines are tokenized with the bert-base-uncased
|
||||
WordPiece tokenizer (vocabulary 30,522). Four users carry four disjoint
|
||||
headline streams, each frame transmits one token per user, and the token
|
||||
identifier is carried by its base-16 digits, so the digit space
|
||||
16^4 = 65,536 covers the vocabulary. The keys and codebook trained on
|
||||
uniform indices are reused unchanged, so this stage tests the design on
|
||||
a real, highly non-uniform source without retraining.
|
||||
|
||||
Two metrics are reported. The token error rate is the symbol-level
|
||||
measure used in the rest of the paper. The headline recovery rate is a
|
||||
meaning-level measure: the fraction of complete headlines a receiver
|
||||
reconstructs without a single token error, which is what an
|
||||
eavesdropper actually needs to read the message.
|
||||
|
||||
Outputs:
|
||||
real_sec_ter.csv : token error rate vs SNR for legitimate, outsider
|
||||
eavesdropper, insider, and OMA
|
||||
real_sec_stats.json: stream statistics and headline recovery rates
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
|
||||
import torch
|
||||
|
||||
import sse_lib as L
|
||||
from sse_lib import (DATA, DEVICE, SSE, rayleigh_gain, snr_to_sigma2,
|
||||
set_seed, write_csv)
|
||||
from exp_full import get_model, eve_wrong_mask
|
||||
|
||||
SNR_GRID = [0, 4, 8, 12, 16, 20, 24, 28]
|
||||
# headline recovery is meaningful only where the legitimate user clears
|
||||
# most tokens, since a headline averages tens of tokens and needs every
|
||||
# one of them correct
|
||||
REC_SNR = (20, 24, 28)
|
||||
N_TEXTS = 2000
|
||||
REPEATS = 8
|
||||
REC_RUNS = 4
|
||||
SEED_EVAL = 777
|
||||
P_MAX, VU, U = 4, 16, 4
|
||||
|
||||
|
||||
def load_streams():
|
||||
from datasets import load_dataset
|
||||
from transformers import AutoTokenizer
|
||||
tok = AutoTokenizer.from_pretrained("bert-base-uncased")
|
||||
ds = load_dataset("fancyzhx/ag_news", split="test")
|
||||
texts = [ds[i]["text"] for i in range(N_TEXTS)]
|
||||
streams = [[] for _ in range(U)]
|
||||
bounds = [[] for _ in range(U)] # (start, end) per headline
|
||||
for i, t in enumerate(texts):
|
||||
ids = tok(t, add_special_tokens=False)["input_ids"]
|
||||
u = i % U
|
||||
s = len(streams[u])
|
||||
streams[u].extend(ids)
|
||||
bounds[u].append((s, s + len(ids)))
|
||||
n = min(len(s) for s in streams)
|
||||
streams = [s[:n] for s in streams]
|
||||
bounds = [[(a, b) for (a, b) in bu if b <= n] for bu in bounds]
|
||||
return streams, bounds, tok.vocab_size
|
||||
|
||||
|
||||
def ids_to_digits(ids: torch.Tensor) -> torch.Tensor:
|
||||
"""(N,U) token ids -> (N,U,P) base-16 digits, most significant first."""
|
||||
d, x = [], ids.clone()
|
||||
for _ in range(P_MAX):
|
||||
d.append(x % VU)
|
||||
x = x // VU
|
||||
return torch.stack(d[::-1], dim=-1)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def wrong_keyed(model: SSE, digits_all, snr_db, seed, rx_masks=None,
|
||||
chunk=50_000):
|
||||
"""Per-frame per-user error indicator (N,U) for a receiver that
|
||||
correlates with rx_masks. rx_masks=None means the legitimate keys."""
|
||||
torch.manual_seed(seed)
|
||||
Bn = model.unit_codebook()
|
||||
true_m = model.masks()
|
||||
rx = true_m if rx_masks is None else rx_masks.to(DEVICE)
|
||||
c = model.c
|
||||
N = digits_all.shape[0]
|
||||
wrong = torch.zeros(N, U, dtype=torch.bool)
|
||||
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
|
||||
for n0 in range(0, N, chunk):
|
||||
dg = digits_all[n0:n0 + chunk].to(DEVICE)
|
||||
n = dg.shape[0]
|
||||
e = Bn[dg] / math.sqrt(model.P)
|
||||
y = (e * true_m[None, :, None, :]).sum(dim=1) / c
|
||||
h = rayleigh_gain((n, U), device=DEVICE)
|
||||
noise = torch.randn(n, U, model.P, model.L, device=DEVICE)
|
||||
y_rx = h[:, :, None, None] * y[:, None] + sigma * noise
|
||||
r = y_rx / h[:, :, None, None].clamp_min(1e-6)
|
||||
cand = Bn[None, :, :] * rx[:, None, :]
|
||||
sc = torch.einsum("nupl,uvl->nupv", r, cand)
|
||||
wrong[n0:n0 + chunk] = (sc.argmax(-1) != dg).any(dim=2).cpu()
|
||||
return wrong
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def wrong_oma(ids_all, snr_db, seed, bits=16):
|
||||
"""Antipodal signaling on the actual token bits, same frame energy."""
|
||||
torch.manual_seed(seed)
|
||||
N, Uu = ids_all.shape
|
||||
b = ((ids_all[..., None] >> torch.arange(bits)) & 1).float() * 2 - 1
|
||||
b = b.to(DEVICE)
|
||||
sigma = math.sqrt(1.0 / (10.0 ** (snr_db / 10.0)))
|
||||
h = rayleigh_gain((N, Uu, 1))
|
||||
y = h * b + sigma * torch.randn(N, Uu, bits, device=DEVICE)
|
||||
return ((y * b) < 0).any(dim=2).cpu()
|
||||
|
||||
|
||||
def headline_recovery(wrong: torch.Tensor, bounds) -> tuple[int, int]:
|
||||
"""A headline counts as recovered only if every token is correct."""
|
||||
ok = tot = 0
|
||||
for u in range(U):
|
||||
wu = wrong[:, u]
|
||||
for (a, b) in bounds[u]:
|
||||
tot += 1
|
||||
ok += int(not bool(wu[a:b].any()))
|
||||
return ok, tot
|
||||
|
||||
|
||||
def main():
|
||||
set_seed(SEED_EVAL)
|
||||
streams, bounds, vocab = load_streams()
|
||||
ids_all = torch.tensor(list(zip(*streams)), dtype=torch.long) # (N,U)
|
||||
digits_all = ids_to_digits(ids_all)
|
||||
N = ids_all.shape[0]
|
||||
assert int(ids_all.max()) < VU ** P_MAX
|
||||
|
||||
print(f"[real] {N} frames, {int(torch.unique(ids_all).numel())} "
|
||||
f"distinct tokens, max id {int(ids_all.max())}")
|
||||
|
||||
# keys and codebook trained on uniform indices, reused unchanged
|
||||
model = get_model(P=P_MAX, vu=VU, d=64, U=U, iters=4000)
|
||||
model.eval()
|
||||
|
||||
eve_m = eve_wrong_mask(U, model.L, seed=20260813) # outsider
|
||||
ins_m = model.masks().detach().roll(1, 0).cpu() # insider
|
||||
|
||||
schemes = {
|
||||
"legit": lambda s, k: wrong_keyed(model, digits_all, s, k),
|
||||
"eve": lambda s, k: wrong_keyed(model, digits_all, s, k, eve_m),
|
||||
"insider": lambda s, k: wrong_keyed(model, digits_all, s, k, ins_m),
|
||||
"oma": lambda s, k: wrong_oma(ids_all, s, k),
|
||||
}
|
||||
|
||||
rows = []
|
||||
for s in SNR_GRID:
|
||||
ter = {}
|
||||
for name, fn in schemes.items():
|
||||
e = 0
|
||||
for r in range(REPEATS):
|
||||
e += int(fn(s, SEED_EVAL + 1000 * r + int(10 * s)).sum())
|
||||
ter[name] = e / (N * U * REPEATS)
|
||||
rows.append((s, ter["legit"], ter["eve"], ter["insider"], ter["oma"]))
|
||||
print("[real]", [f"{v:.4g}" for v in rows[-1]])
|
||||
write_csv(DATA / "real_sec_ter.csv",
|
||||
["snr_db", "ter_legit", "ter_eve", "ter_insider", "ter_oma"],
|
||||
rows)
|
||||
|
||||
rec = {}
|
||||
for s in REC_SNR:
|
||||
rec[str(s)] = {}
|
||||
for name, fn in schemes.items():
|
||||
ok = tot = 0
|
||||
for r in range(REC_RUNS):
|
||||
w = fn(s, SEED_EVAL + 5000 * r + int(10 * s))
|
||||
o, t = headline_recovery(w, bounds)
|
||||
ok += o; tot += t
|
||||
rec[str(s)][name] = ok / tot
|
||||
print(f"[rec] {s} dB {name}: {ok}/{tot} = {ok/tot:.4g}")
|
||||
|
||||
stats = {
|
||||
"vocab_size": vocab,
|
||||
"n_texts": N_TEXTS,
|
||||
"frames": N,
|
||||
"repeats": REPEATS,
|
||||
"decisions_per_point": N * U * REPEATS,
|
||||
"distinct_tokens": int(torch.unique(ids_all).numel()),
|
||||
"max_token_id": int(ids_all.max()),
|
||||
"headlines_scored": sum(len(b) for b in bounds),
|
||||
"headline_runs": REC_RUNS,
|
||||
"recovery": rec,
|
||||
}
|
||||
(DATA / "real_sec_stats.json").write_text(json.dumps(stats, indent=1))
|
||||
print(json.dumps(stats, indent=1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Feasibility study for paper 11 (TIFS): the per-user mask as a
|
||||
physical-layer key.
|
||||
|
||||
Three questions, all under the shared-embedding multiple-access model of
|
||||
sse_lib.py (real-vector convention, flat Rayleigh fading):
|
||||
|
||||
Q1 (encryption): a legitimate receiver knows its mask mu_u; an
|
||||
eavesdropper (Eve) does not. How far above chance can Eve decode?
|
||||
We measure the legitimate symbol error rate (SER) against Eve's SER
|
||||
when Eve applies (a) a wrong mask drawn from the same distribution,
|
||||
(b) no mask (mu = 1), (c) the average mask. Chance level is
|
||||
(Vu-1)/Vu per digit, 1-(1/Vu)^P per frame.
|
||||
|
||||
Q2 (key entropy vs dimension): as the per-period length L grows, two
|
||||
independently drawn unit-norm masks become more nearly orthogonal,
|
||||
so Eve's residual after de-masking with a wrong key grows. We sweep
|
||||
L and report Eve's SER and the mean absolute mask cross-correlation.
|
||||
|
||||
Q3 (jamming robustness): a jammer adds h_J * w to the frame, where w is
|
||||
an arbitrary unit waveform (worst case: aligned with the victim's
|
||||
masked codeword direction; and random). We sweep the
|
||||
jammer-to-signal ratio (JSR) and report the legitimate SER, to show
|
||||
the mask spreads a mismatched jammer and bounds its effect.
|
||||
|
||||
This is a CPU-sized feasibility run (small V), not the final experiment.
|
||||
Seeds fixed; results written to ../data as CSV.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
import sse_lib as L
|
||||
from sse_lib import SSE, rayleigh_gain, snr_to_sigma2, write_csv, set_seed, DATA, DEVICE
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Eve: apply a chosen (wrong) set of masks to the SAME received frame the
|
||||
# legitimate users see, then run the correlation receiver.
|
||||
# ----------------------------------------------------------------------
|
||||
@torch.no_grad()
|
||||
def eval_ser_eve(model: SSE, eve_masks: torch.Tensor, snr_list,
|
||||
frames: int = 400_000, chunk: int = 50_000, seed: int = 777):
|
||||
"""eve_masks: (U, L) the masks Eve uses in place of the true ones.
|
||||
Eve observes the same physically transmitted frame (true masks used at
|
||||
the transmitter) but correlates with eve_masks."""
|
||||
model.eval().to(DEVICE)
|
||||
Bn = model.unit_codebook()
|
||||
true_m = model.masks()
|
||||
eve_masks = eve_masks.to(DEVICE)
|
||||
c = model.c
|
||||
out = []
|
||||
for snr_db in snr_list:
|
||||
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
|
||||
err = tot = 0
|
||||
for n0 in range(0, frames, chunk):
|
||||
n = min(chunk, frames - n0)
|
||||
digits = torch.randint(model.vu, (n, model.users, model.P),
|
||||
generator=g).to(DEVICE)
|
||||
# transmit with the TRUE masks
|
||||
e = Bn[digits] / math.sqrt(model.P)
|
||||
y = (e * true_m[None, :, None, :]).sum(dim=1) / c # (n,P,L)
|
||||
h = rayleigh_gain((n, model.users), device=DEVICE)
|
||||
sigma = snr_to_sigma2(snr_db).to(DEVICE).sqrt()
|
||||
noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE)
|
||||
y_rx = h[:, :, None, None] * y[:, None] + sigma * noise
|
||||
r = y_rx / h[:, :, None, None].clamp_min(1e-6) # (n,U,P,L)
|
||||
# Eve correlates with her (wrong) masks
|
||||
cand = Bn[None, :, :] * eve_masks[:, None, :] # (U,Vu,L)
|
||||
scores = torch.einsum("nupl,uvl->nupv", r, cand)
|
||||
wrong = (scores.argmax(-1) != digits).any(dim=2)
|
||||
err += int(wrong.sum()); tot += n * model.users
|
||||
out.append(err / tot)
|
||||
return out
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def eval_ser_jam(model: SSE, snr_db, jsr_db_list, frames: int = 400_000,
|
||||
chunk: int = 50_000, seed: int = 777, mode: str = "aligned"):
|
||||
"""Legitimate SER with an added jammer h_J * sqrt(JSR) * w.
|
||||
mode='aligned': w points along user 0's masked mean codeword direction
|
||||
(a structured, mask-matched worst case for user 0).
|
||||
mode='random': w is an isotropic random unit frame each transmission."""
|
||||
model.eval().to(DEVICE)
|
||||
Bn = model.unit_codebook()
|
||||
true_m = model.masks()
|
||||
c = model.c
|
||||
sigma = snr_to_sigma2(snr_db).to(DEVICE).sqrt()
|
||||
# aligned jammer direction: mask-0 applied to a fixed unit codeword,
|
||||
# i.e. what an attacker would build if it copied the public codebook
|
||||
# but guessed the (secret) mask wrong -> here we give it mask 0 exactly
|
||||
# as the strongest realistic structured jammer.
|
||||
w_fixed = (Bn[0][None, :] * true_m[0][None, :]).repeat(model.P, 1) # (P,L)
|
||||
w_fixed = w_fixed / w_fixed.norm()
|
||||
out = []
|
||||
for jsr_db in jsr_db_list:
|
||||
jsr = 10.0 ** (jsr_db / 10.0)
|
||||
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * jsr_db))
|
||||
err = tot = 0
|
||||
for n0 in range(0, frames, chunk):
|
||||
n = min(chunk, frames - n0)
|
||||
digits = torch.randint(model.vu, (n, model.users, model.P),
|
||||
generator=g).to(DEVICE)
|
||||
e = Bn[digits] / math.sqrt(model.P)
|
||||
y = (e * true_m[None, :, None, :]).sum(dim=1) / c # (n,P,L)
|
||||
h = rayleigh_gain((n, model.users), device=DEVICE)
|
||||
hJ = rayleigh_gain((n,), device=DEVICE)
|
||||
if mode == "aligned":
|
||||
w = w_fixed[None].expand(n, model.P, model.L)
|
||||
else:
|
||||
w = torch.randn(n, model.P, model.L, device=DEVICE)
|
||||
w = w / w.reshape(n, -1).norm(dim=1)[:, None, None].clamp_min(1e-8)
|
||||
jam = (hJ * math.sqrt(jsr))[:, None, None] * w # (n,P,L)
|
||||
noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE)
|
||||
y_rx = (h[:, :, None, None] * y[:, None]
|
||||
+ h[:, :, None, None] * 0 # keep shape clarity
|
||||
+ jam[:, None] + sigma * noise)
|
||||
r = y_rx / h[:, :, None, None].clamp_min(1e-6)
|
||||
cand = Bn[None, :, :] * true_m[:, None, :]
|
||||
scores = torch.einsum("nupl,uvl->nupv", r, cand)
|
||||
wrong = (scores.argmax(-1) != digits).any(dim=2)
|
||||
err += int(wrong.sum()); tot += n * model.users
|
||||
out.append(err / tot)
|
||||
return out
|
||||
|
||||
|
||||
def mean_abs_cross_corr(masks: torch.Tensor) -> float:
|
||||
"""Mean |<mu_i, mu_j>| / (||mu_i|| ||mu_j||) over i<j."""
|
||||
m = masks / masks.norm(dim=1, keepdim=True).clamp_min(1e-8)
|
||||
G = (m @ m.T).abs()
|
||||
U = m.shape[0]
|
||||
off = G[~torch.eye(U, dtype=torch.bool, device=G.device)]
|
||||
return float(off.mean())
|
||||
|
||||
|
||||
def main():
|
||||
set_seed(1)
|
||||
# CPU-sized feasibility configuration: V = Vu^P = 16^2 = 256
|
||||
P, VU, D, U = 2, 16, 64, 4
|
||||
snr_eval = [0.0, 5.0, 10.0, 15.0, 20.0]
|
||||
chance_frame = 1.0 - (1.0 / VU) ** P
|
||||
|
||||
model = SSE(P=P, vu=VU, d=D, users=U).to(DEVICE)
|
||||
print(f"[train] SSE P={P} Vu={VU} d={D} U={U} V={model.V} on {DEVICE}")
|
||||
L.TRAIN_SNR_DB = (0.0, 20.0)
|
||||
model_iters = 1500
|
||||
curve = L.train_sse(model, iters=model_iters, batch=256, lr=3e-3,
|
||||
log_every=0, seed=1)
|
||||
model.calibrate_power()
|
||||
|
||||
legit = L.eval_ser_sse(model, snr_eval, frames=400_000)
|
||||
print("[Q1] legitimate SER:", [f"{v:.3g}" for v in legit])
|
||||
|
||||
# Eve variants
|
||||
set_seed(20260813)
|
||||
eve_wrong = torch.randn(U, model.L) / math.sqrt(model.L)
|
||||
eve_wrong = eve_wrong / eve_wrong.norm(dim=1, keepdim=True) * math.sqrt(model.L)
|
||||
eve_none = torch.ones(U, model.L)
|
||||
eve_avg = model.masks().mean(dim=0, keepdim=True).repeat(U, 1).cpu()
|
||||
|
||||
eve_w = eval_ser_eve(model, eve_wrong, snr_eval, frames=400_000)
|
||||
eve_n = eval_ser_eve(model, eve_none, snr_eval, frames=400_000)
|
||||
eve_a = eval_ser_eve(model, eve_avg, snr_eval, frames=400_000)
|
||||
print("[Q1] Eve wrong-mask SER:", [f"{v:.3g}" for v in eve_w])
|
||||
print("[Q1] Eve no-mask SER:", [f"{v:.3g}" for v in eve_n])
|
||||
print(f"[Q1] chance frame SER = {chance_frame:.4f}")
|
||||
|
||||
write_csv(DATA / "feas_q1_eavesdrop.csv",
|
||||
["snr_db", "legit", "eve_wrong", "eve_none", "eve_avg", "chance"],
|
||||
[(s, legit[i], eve_w[i], eve_n[i], eve_a[i], chance_frame)
|
||||
for i, s in enumerate(snr_eval)])
|
||||
|
||||
# Q2: key entropy vs per-period length L (grow d at fixed P)
|
||||
print("[Q2] sweeping period length L ...")
|
||||
q2_rows = []
|
||||
for d in [16, 32, 64, 128, 256]:
|
||||
set_seed(1)
|
||||
mdl = SSE(P=P, vu=VU, d=d, users=U).to(DEVICE)
|
||||
L.train_sse(mdl, iters=model_iters, batch=256, lr=3e-3, seed=1)
|
||||
mdl.calibrate_power()
|
||||
set_seed(20260813)
|
||||
ew = torch.randn(U, mdl.L) / math.sqrt(mdl.L)
|
||||
ew = ew / ew.norm(dim=1, keepdim=True) * math.sqrt(mdl.L)
|
||||
lg = L.eval_ser_sse(mdl, [10.0], frames=300_000)[0]
|
||||
ev = eval_ser_eve(mdl, ew, [10.0], frames=300_000)[0]
|
||||
xc = mean_abs_cross_corr(mdl.masks().detach().cpu())
|
||||
q2_rows.append((mdl.L, d, lg, ev, xc))
|
||||
print(f" L={mdl.L:4d} legit={lg:.3g} eve={ev:.3g} |xcorr|={xc:.3f}")
|
||||
write_csv(DATA / "feas_q2_keyentropy.csv",
|
||||
["L", "d", "legit_ser", "eve_ser", "mask_xcorr"], q2_rows)
|
||||
|
||||
# Q3: jamming robustness at SNR=10 dB
|
||||
print("[Q3] jamming sweep at SNR=10 dB ...")
|
||||
jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0]
|
||||
jam_al = eval_ser_jam(model, 10.0, jsr, frames=300_000, mode="aligned")
|
||||
jam_rd = eval_ser_jam(model, 10.0, jsr, frames=300_000, mode="random")
|
||||
print("[Q3] aligned-jammer SER:", [f"{v:.3g}" for v in jam_al])
|
||||
print("[Q3] random-jammer SER:", [f"{v:.3g}" for v in jam_rd])
|
||||
write_csv(DATA / "feas_q3_jamming.csv",
|
||||
["jsr_db", "ser_aligned", "ser_random"],
|
||||
[(j, jam_al[i], jam_rd[i]) for i, j in enumerate(jsr)])
|
||||
|
||||
print("\n[done] feasibility CSVs written to", DATA)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Generate the LaTeX rows of the two result tables from the CSVs, so
|
||||
that every table in the paper is reproducible from data/ (TIFS mandate).
|
||||
Prints the tabular body; paste into main.tex without edits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
DATA = Path(__file__).resolve().parents[1] / "data"
|
||||
|
||||
NAME = {
|
||||
"proposed": r"\textbf{Proposed keyed masking}",
|
||||
"public_mask": "Public masks",
|
||||
"perm_key": r"Global-permutation key~\cite{chen2023shuffling}",
|
||||
"index_cipher": "Per-user index cipher",
|
||||
"oma_plain": "OMA (no encryption)",
|
||||
"random": "Random",
|
||||
"hadamard": "Walsh-Hadamard",
|
||||
"learned": "Learned",
|
||||
}
|
||||
|
||||
|
||||
def f3(x: str) -> str:
|
||||
try:
|
||||
return f"{float(x):.3f}"
|
||||
except ValueError:
|
||||
return "--"
|
||||
|
||||
|
||||
def compare_table():
|
||||
print("% Table: scheme comparison (from sec_compare.csv)")
|
||||
rows = list(csv.DictReader(open(DATA / "sec_compare.csv")))
|
||||
order = ["public_mask", "perm_key", "index_cipher", "oma_plain", "proposed"]
|
||||
rows = sorted(rows, key=lambda r: order.index(r["scheme"]))
|
||||
for r in rows:
|
||||
cells = [f3(r["legit_ser"]), f3(r["eve_out"]), f3(r["eve_in"]),
|
||||
f3(r["jam0_ser"])]
|
||||
if r["scheme"] == "proposed":
|
||||
cells = [rf"$\mathbf{{{c}}}$" for c in cells]
|
||||
else:
|
||||
cells = [f"${c}$" if c != "--" else "--" for c in cells]
|
||||
print(f"{NAME[r['scheme']]} & " + " & ".join(cells) + r" \\")
|
||||
|
||||
|
||||
def maskfam_table():
|
||||
print("% Table: key families (from sec_maskfam.csv)")
|
||||
for r in csv.DictReader(open(DATA / "sec_maskfam.csv")):
|
||||
print(f"{NAME[r['family']]} & ${f3(r['legit_ser'])}$ & "
|
||||
f"${f3(r['eve_ser'])}$ & ${f3(r['mask_xcorr'])}$" + r" \\")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
compare_table()
|
||||
print()
|
||||
maskfam_table()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Canonical replot script for paper 11: regenerates every result figure
|
||||
from ../data/*.csv and writes paper-ready PDFs to ../fig/. No experiment
|
||||
is rerun. All result plots share one canvas and axes rectangle (8:6 box).
|
||||
Label dictionary is fixed here and copied verbatim into tables and prose.
|
||||
|
||||
fig_sec_snr.pdf : legitimate vs eavesdropper SER vs SNR (Fig. 2)
|
||||
fig_sec_keylen.pdf : SER vs key length L (Fig. 3)
|
||||
fig_sec_jam.pdf : target-user SER vs JSR (Fig. 4)
|
||||
fig_sec_sens.pdf : Eve SER vs key correlation (Fig. 5)
|
||||
fig_sec_brute.pdf : Eve SER vs number of key guesses (Fig. 6)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import csv
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA = ROOT / "data"
|
||||
FIG = ROOT / "fig"
|
||||
FIG.mkdir(exist_ok=True)
|
||||
|
||||
plt.rcParams.update({
|
||||
"font.family": "serif",
|
||||
"font.serif": ["DejaVu Serif", "Times New Roman"],
|
||||
"font.size": 9,
|
||||
"axes.labelsize": 9,
|
||||
"legend.fontsize": 6.6,
|
||||
"xtick.labelsize": 8,
|
||||
"ytick.labelsize": 8,
|
||||
"axes.grid": True,
|
||||
"grid.linestyle": "--",
|
||||
"grid.linewidth": 0.4,
|
||||
"grid.alpha": 0.6,
|
||||
"lines.linewidth": 1.3,
|
||||
"lines.markersize": 3.4,
|
||||
"figure.figsize": (3.15, 2.36),
|
||||
"pdf.fonttype": 42,
|
||||
})
|
||||
AXES_RECT = dict(left=0.185, right=0.965, top=0.955, bottom=0.195)
|
||||
|
||||
C_LEGIT = "#c0392b"
|
||||
C_EVE = "#2c5fa8"
|
||||
C_OMA = "#7f8c8d"
|
||||
C_CH = "#95a5a6"
|
||||
C_MATCH = "#8e44ad"
|
||||
C_PUB = "#16a085"
|
||||
|
||||
# fixed label dictionary: tables and prose copy these strings verbatim
|
||||
LBL = {
|
||||
"legit": "Legitimate",
|
||||
"oma": "OMA",
|
||||
"eve_pub": "Eve, public masks",
|
||||
"eve_key": "Eve, wrong key",
|
||||
"chance": "Random guess",
|
||||
"jam_m": "Matched jammer (public masks)",
|
||||
"jam_b": "Blind jammer (proposed)",
|
||||
"nojam": "No jammer",
|
||||
}
|
||||
|
||||
|
||||
def load(name):
|
||||
with open(DATA / name) as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
def col(rows, k, f=float):
|
||||
return [f(r[k]) for r in rows]
|
||||
|
||||
|
||||
def save(fig, name):
|
||||
"""Write the figure and assert that no axis label is clipped.
|
||||
|
||||
A long y label, or wide minor tick labels such as 6x10^-1 on a log
|
||||
axis that spans less than a decade, silently pushes the label off
|
||||
the canvas under the fixed axes rectangle. Reading the plotting code
|
||||
cannot reveal this, so the check is made on the rendered geometry.
|
||||
"""
|
||||
fig.subplots_adjust(**AXES_RECT)
|
||||
fig.canvas.draw()
|
||||
fbox = fig.get_window_extent()
|
||||
for ax in fig.axes:
|
||||
for lbl in (ax.yaxis.label, ax.xaxis.label):
|
||||
if not lbl.get_text():
|
||||
continue
|
||||
b = lbl.get_window_extent()
|
||||
if (b.x0 < fbox.x0 or b.y0 < fbox.y0
|
||||
or b.x1 > fbox.x1 or b.y1 > fbox.y1):
|
||||
raise RuntimeError(
|
||||
f"{name}: axis label '{lbl.get_text()}' is clipped "
|
||||
f"(label {b} outside figure {fbox}); shorten the "
|
||||
f"label or widen the margin")
|
||||
fig.savefig(FIG / f"{name}.pdf")
|
||||
plt.close(fig)
|
||||
print("[OK]", name)
|
||||
|
||||
|
||||
def fig_snr():
|
||||
r = load("sec_snr.csv")
|
||||
x = col(r, "snr_db")
|
||||
fig, ax = plt.subplots()
|
||||
ax.semilogy(x, col(r, "legit"), color=C_LEGIT, marker="o", ls="-",
|
||||
label=LBL["legit"])
|
||||
ax.semilogy(x, col(r, "oma"), color=C_OMA, marker="^", ls=":",
|
||||
label=LBL["oma"])
|
||||
ax.semilogy(x, col(r, "eve_public"), color=C_PUB, marker="v",
|
||||
ls="none", markersize=5.2, markerfacecolor="none",
|
||||
label=LBL["eve_pub"])
|
||||
ax.semilogy(x, col(r, "eve_wrong"), color=C_EVE, marker="s", ls="--",
|
||||
label=LBL["eve_key"])
|
||||
ax.plot(x, col(r, "chance"), color=C_CH, ls="-.", lw=0.9,
|
||||
label=LBL["chance"])
|
||||
ax.set_xlabel("SNR (dB)")
|
||||
ax.set_ylabel("SER")
|
||||
ax.set_xlim(min(x), max(x))
|
||||
ax.legend(loc="lower left")
|
||||
save(fig, "fig_sec_snr")
|
||||
|
||||
|
||||
def fig_keylen():
|
||||
r = load("sec_keylen.csv")
|
||||
x = col(r, "L", int)
|
||||
fig, ax = plt.subplots()
|
||||
ax.semilogy(x, col(r, "legit_ser"), color=C_LEGIT, marker="o", ls="-",
|
||||
label=LBL["legit"])
|
||||
ax.semilogy(x, col(r, "oma"), color=C_OMA, marker="^", ls=":",
|
||||
label=LBL["oma"])
|
||||
ax.semilogy(x, col(r, "eve_ser"), color=C_EVE, marker="s", ls="--",
|
||||
label=LBL["eve_key"])
|
||||
ax.set_xlabel("Key length $L$")
|
||||
ax.set_ylabel("SER")
|
||||
ax.set_xscale("log", base=2)
|
||||
ax.legend(loc="center right", bbox_to_anchor=(0.98, 0.72))
|
||||
save(fig, "fig_sec_keylen")
|
||||
|
||||
|
||||
def fig_jam():
|
||||
# the target-user SER spans 0.3 to 1.0, less than one decade, so a
|
||||
# linear axis is used: a log axis here produces wide minor tick
|
||||
# labels (6x10^-1) that crowd out the y label under the fixed
|
||||
# axes rectangle
|
||||
r = load("sec_jam.csv")
|
||||
x = col(r, "jsr_db")
|
||||
fig, ax = plt.subplots()
|
||||
ax.plot(x, col(r, "matched"), color=C_MATCH, marker="P", ls="--",
|
||||
label=LBL["jam_m"])
|
||||
ax.plot(x, col(r, "blind"), color=C_LEGIT, marker="o", ls="-",
|
||||
label=LBL["jam_b"])
|
||||
nojam = col(r, "nojam")[0]
|
||||
ax.axhline(nojam, color=C_OMA, ls=":", lw=0.9, label=LBL["nojam"])
|
||||
ax.set_xlabel("JSR (dB)")
|
||||
ax.set_ylabel("SER")
|
||||
ax.set_xlim(min(x), max(x))
|
||||
ax.set_ylim(0.2, 1.02)
|
||||
ax.legend(loc="lower right")
|
||||
save(fig, "fig_sec_jam")
|
||||
|
||||
|
||||
def fig_sens():
|
||||
r = load("sec_sens.csv")
|
||||
x = col(r, "rho")
|
||||
fig, ax = plt.subplots()
|
||||
ax.plot(x, col(r, "eve_ser"), color=C_EVE, marker="s", ls="-",
|
||||
label=LBL["eve_key"])
|
||||
chance = 1.0 - (1.0 / 16.0) ** 4
|
||||
ax.axhline(chance, color=C_CH, ls="-.", lw=0.9, label=LBL["chance"])
|
||||
ax.set_xlabel(r"Key correlation $\kappa$")
|
||||
ax.set_ylabel("Eavesdropper SER")
|
||||
ax.set_xlim(0, 1)
|
||||
ax.legend(loc="lower left")
|
||||
save(fig, "fig_sec_sens")
|
||||
|
||||
|
||||
def fig_brute():
|
||||
r = load("sec_brute.csv")
|
||||
fig, ax = plt.subplots()
|
||||
sty = {8: ("#c0392b", "o"), 16: ("#2c5fa8", "s"),
|
||||
32: ("#16a085", "v"), 64: ("#8e44ad", "P")}
|
||||
for Lp in [8, 16, 32, 64]:
|
||||
rows = [row for row in r if int(row["L"]) == Lp]
|
||||
ks = [float(row["K"]) for row in rows]
|
||||
ser = [float(row["eve_ser"]) for row in rows]
|
||||
c, mk = sty[Lp]
|
||||
ax.semilogx(ks, ser, color=c, marker=mk, ls="-",
|
||||
label=f"$L={Lp}$")
|
||||
ax.set_xlabel("Number of key guesses $K$")
|
||||
ax.set_ylabel("Eavesdropper SER")
|
||||
ax.legend(loc="lower left")
|
||||
save(fig, "fig_sec_brute")
|
||||
|
||||
|
||||
def fig_real():
|
||||
r = load("real_sec_ter.csv")
|
||||
x = col(r, "snr_db")
|
||||
fig, ax = plt.subplots()
|
||||
ax.semilogy(x, col(r, "ter_legit"), color=C_LEGIT, marker="o", ls="-",
|
||||
label=LBL["legit"])
|
||||
ax.semilogy(x, col(r, "ter_oma"), color=C_OMA, marker="^", ls=":",
|
||||
label=LBL["oma"])
|
||||
ax.semilogy(x, col(r, "ter_insider"), color=C_PUB, marker="v", ls="-.",
|
||||
label="Insider")
|
||||
ax.semilogy(x, col(r, "ter_eve"), color=C_EVE, marker="s", ls="--",
|
||||
label=LBL["eve_key"])
|
||||
ax.set_xlabel("SNR (dB)")
|
||||
ax.set_ylabel("TER")
|
||||
ax.set_xlim(min(x), max(x))
|
||||
ax.legend(loc="lower left")
|
||||
save(fig, "fig_sec_real")
|
||||
|
||||
|
||||
def fig_kpa():
|
||||
r = load("kpa.csv")
|
||||
fig, ax = plt.subplots()
|
||||
sty = {0.0: ("#c0392b", "o"), 10.0: ("#2c5fa8", "s"),
|
||||
20.0: ("#16a085", "v")}
|
||||
for snr, (c, mk) in sty.items():
|
||||
rows = [row for row in r if float(row["snr_db"]) == snr]
|
||||
n = [float(row["n_frames"]) for row in rows]
|
||||
ser = [float(row["eve_ser"]) for row in rows]
|
||||
ax.semilogx(n, ser, color=c, marker=mk, ls="-",
|
||||
label=f"{int(snr)} dB")
|
||||
ax.axhline(0.304, color=C_OMA, ls=":", lw=0.9, label=LBL["legit"])
|
||||
ax.set_xlabel("Known-plaintext frames $N$")
|
||||
ax.set_ylabel("Eavesdropper SER")
|
||||
ax.set_xscale("log", base=2)
|
||||
ax.legend(loc="upper right")
|
||||
save(fig, "fig_sec_kpa")
|
||||
|
||||
|
||||
def main():
|
||||
fig_snr()
|
||||
fig_keylen()
|
||||
fig_jam()
|
||||
try:
|
||||
fig_sens()
|
||||
fig_brute()
|
||||
except FileNotFoundError:
|
||||
print("[skip] attack-difficulty CSVs not present yet")
|
||||
try:
|
||||
fig_real()
|
||||
except FileNotFoundError:
|
||||
print("[skip] real-token CSV not present yet")
|
||||
try:
|
||||
fig_kpa()
|
||||
except FileNotFoundError:
|
||||
print("[skip] known-plaintext CSV not present yet")
|
||||
print("[done] figures in", FIG)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
"""Shared library for the scalable shared embedding (SSE) letter.
|
||||
|
||||
System model (real-vector convention, declared in the paper):
|
||||
d-dimensional real embedding frame, U users, single transmitter.
|
||||
Proposed SSE: the frame is split into P periods of length L = d/P and
|
||||
one unit codebook B in R^{Vu x L} is reused in every period, so the
|
||||
vocabulary size is V = Vu^P while the codebook stores only Vu*L
|
||||
numbers. Index v maps to base-Vu digits (i_1,...,i_P).
|
||||
Per-user periodic masks mu_u in R^L are repeated over the P periods.
|
||||
Tx: y = (1/c) * sum_u e(s_u) .* m_u, c fixes unit average frame power.
|
||||
Channel: user u sees h_u * y + n, h_u^2 ~ Exp(1) (Rayleigh magnitude,
|
||||
known at the receiver), n ~ N(0, sigma^2 I), sigma^2 = 1/(d*snr).
|
||||
Rx u: equalize by h_u, per period correlate with the masked unit
|
||||
codewords b_i .* mu_u and take the argmax digit.
|
||||
|
||||
Device: CUDA when available (run under WSL), CPU fallback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA = ROOT / "data"
|
||||
FIG = ROOT / "fig"
|
||||
DATA.mkdir(exist_ok=True)
|
||||
FIG.mkdir(exist_ok=True)
|
||||
|
||||
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# global configuration
|
||||
# ----------------------------------------------------------------------
|
||||
D = 64 # embedding dimension (real)
|
||||
U = 4 # users
|
||||
VU = 16 # unit codebook size
|
||||
P_MAX = 4 # periods for the main configuration, V = 16^4 = 65536
|
||||
SEED = 1
|
||||
|
||||
TRAIN_ITERS = 4000
|
||||
TRAIN_BATCH = 256
|
||||
TRAIN_SNR_DB = (0.0, 20.0)
|
||||
LR = 3e-3
|
||||
|
||||
|
||||
def set_seed(seed: int = SEED) -> None:
|
||||
torch.manual_seed(seed)
|
||||
np.random.seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(seed)
|
||||
|
||||
|
||||
def snr_to_sigma2(snr_db: torch.Tensor | float,
|
||||
d: int = D) -> torch.Tensor | float:
|
||||
"""Per-dimension noise variance for unit frame power and E[h^2]=1.
|
||||
Pass the model's actual frame dimension d when it differs from the
|
||||
module default, otherwise the effective SNR shifts with d."""
|
||||
snr = 10.0 ** (torch.as_tensor(snr_db, dtype=torch.float64) / 10.0)
|
||||
return (1.0 / (d * snr)).float()
|
||||
|
||||
|
||||
def rayleigh_gain(shape, device=DEVICE) -> torch.Tensor:
|
||||
"""|g| with g ~ CN(0,1): h^2 ~ Exp(1), E[h^2] = 1."""
|
||||
u = torch.rand(shape, device=device).clamp_min(1e-12)
|
||||
return torch.sqrt(-torch.log(u))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# proposed scalable shared embedding
|
||||
# ----------------------------------------------------------------------
|
||||
class SSE(torch.nn.Module):
|
||||
"""Periodic unit-codebook shared embedding with per-user periodic masks."""
|
||||
|
||||
def __init__(self, P: int = P_MAX, vu: int = VU, d: int = D, users: int = U):
|
||||
super().__init__()
|
||||
assert d % P == 0, "embedding dimension must split into P periods"
|
||||
self.P, self.vu, self.d, self.users = P, vu, d, users
|
||||
self.L = d // P
|
||||
self.B = torch.nn.Parameter(torch.randn(vu, self.L) / math.sqrt(self.L))
|
||||
self.W = torch.nn.Parameter(torch.randn(users, self.L) / math.sqrt(self.L))
|
||||
self.logit_scale = torch.nn.Parameter(torch.tensor(2.0))
|
||||
# transmit power normalizer, calibrated after training (buffer)
|
||||
self.register_buffer("c", torch.tensor(1.0))
|
||||
|
||||
@property
|
||||
def V(self) -> int:
|
||||
return self.vu ** self.P
|
||||
|
||||
def unit_codebook(self) -> torch.Tensor:
|
||||
return self.B / self.B.norm(dim=1, keepdim=True).clamp_min(1e-8)
|
||||
|
||||
def masks(self) -> torch.Tensor:
|
||||
return self.W / self.W.norm(dim=1, keepdim=True).clamp_min(1e-8) * math.sqrt(self.L)
|
||||
|
||||
def tx_frame(self, digits: torch.Tensor) -> torch.Tensor:
|
||||
"""digits: (N, U, P) ints -> unnormalized tx frame (N, P, L)."""
|
||||
Bn = self.unit_codebook()
|
||||
e = Bn[digits] / math.sqrt(self.P) # (N,U,P,L)
|
||||
m = self.masks() # (U,L)
|
||||
x = e * m[None, :, None, :]
|
||||
return x.sum(dim=1) # (N,P,L)
|
||||
|
||||
def calibrate_power(self, n: int = 65536) -> None:
|
||||
with torch.no_grad():
|
||||
digits = torch.randint(self.vu, (n, self.users, self.P), device=self.B.device)
|
||||
y = self.tx_frame(digits)
|
||||
self.c.fill_(float(y.pow(2).sum(dim=(1, 2)).mean().sqrt()))
|
||||
|
||||
def scores(self, r: torch.Tensor) -> torch.Tensor:
|
||||
"""r: equalized rx frame (N,P,L) -> scores (N,U,P,Vu)."""
|
||||
Bn = self.unit_codebook() # (Vu,L)
|
||||
m = self.masks() # (U,L)
|
||||
cand = Bn[None, :, :] * m[:, None, :] # (U,Vu,L)
|
||||
return torch.einsum("npl,uvl->nupv", r, cand)
|
||||
|
||||
def forward(self, digits: torch.Tensor, snr_db: torch.Tensor,
|
||||
h: torch.Tensor | None = None):
|
||||
"""digits (N,U,P), snr_db (N,) -> per-user scores and rx frames."""
|
||||
N = digits.shape[0]
|
||||
y = self.tx_frame(digits) / self.c # (N,P,L)
|
||||
if h is None:
|
||||
h = rayleigh_gain((N, self.users), device=y.device)
|
||||
sigma = snr_to_sigma2(snr_db, self.d).to(y.device).sqrt() # (N,)
|
||||
n = torch.randn(N, self.users, self.P, self.L, device=y.device)
|
||||
y_rx = h[:, :, None, None] * y[:, None] + sigma[:, None, None, None] * n
|
||||
r = y_rx / h[:, :, None, None].clamp_min(1e-6) # equalized (N,U,P,L)
|
||||
Bn = self.unit_codebook()
|
||||
m = self.masks()
|
||||
cand = Bn[None, :, :] * m[:, None, :] # (U,Vu,L)
|
||||
scores = torch.einsum("nupl,uvl->nupv", r, cand)
|
||||
return scores
|
||||
|
||||
def n_params(self) -> int:
|
||||
return self.B.numel() + self.W.numel()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# conventional scheme: full unstructured codebook (prior shared embedding)
|
||||
# ----------------------------------------------------------------------
|
||||
class FullCodebook(torch.nn.Module):
|
||||
"""Directly trained V x d codebook with full-d per-user masks."""
|
||||
|
||||
def __init__(self, V: int, d: int = D, users: int = U):
|
||||
super().__init__()
|
||||
self.Vc, self.d, self.users = V, d, users
|
||||
self.E = torch.nn.Parameter(torch.randn(V, d) / math.sqrt(d))
|
||||
self.W = torch.nn.Parameter(torch.randn(users, d) / math.sqrt(d))
|
||||
self.logit_scale = torch.nn.Parameter(torch.tensor(2.0))
|
||||
self.register_buffer("c", torch.tensor(1.0))
|
||||
|
||||
@property
|
||||
def V(self) -> int:
|
||||
return self.Vc
|
||||
|
||||
def codebook(self) -> torch.Tensor:
|
||||
return self.E / self.E.norm(dim=1, keepdim=True).clamp_min(1e-8)
|
||||
|
||||
def masks(self) -> torch.Tensor:
|
||||
return self.W / self.W.norm(dim=1, keepdim=True).clamp_min(1e-8) * math.sqrt(self.d)
|
||||
|
||||
def tx_frame(self, idx: torch.Tensor) -> torch.Tensor:
|
||||
En = self.codebook()
|
||||
e = En[idx] # (N,U,d)
|
||||
m = self.masks()
|
||||
return (e * m[None]).sum(dim=1) # (N,d)
|
||||
|
||||
def calibrate_power(self, n: int = 65536) -> None:
|
||||
with torch.no_grad():
|
||||
idx = torch.randint(self.Vc, (n, self.users), device=self.E.device)
|
||||
y = self.tx_frame(idx)
|
||||
self.c.fill_(float(y.pow(2).sum(dim=1).mean().sqrt()))
|
||||
|
||||
def forward(self, idx: torch.Tensor, snr_db: torch.Tensor,
|
||||
h: torch.Tensor | None = None):
|
||||
N = idx.shape[0]
|
||||
y = self.tx_frame(idx) / self.c # (N,d)
|
||||
if h is None:
|
||||
h = rayleigh_gain((N, self.users), device=y.device)
|
||||
sigma = snr_to_sigma2(snr_db).to(y.device).sqrt()
|
||||
n = torch.randn(N, self.users, self.d, device=y.device)
|
||||
y_rx = h[:, :, None] * y[:, None] + sigma[:, None, None] * n
|
||||
r = y_rx / h[:, :, None].clamp_min(1e-6) # (N,U,d)
|
||||
En = self.codebook()
|
||||
m = self.masks()
|
||||
cand = En[None, :, :] * m[:, None, :] # (U,V,d)
|
||||
scores = torch.einsum("nud,uvd->nuv", r, cand)
|
||||
return scores
|
||||
|
||||
def n_params(self) -> int:
|
||||
return self.E.numel() + self.W.numel()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# training
|
||||
# ----------------------------------------------------------------------
|
||||
def train_sse(model: SSE, iters: int = TRAIN_ITERS, batch: int = TRAIN_BATCH,
|
||||
lr: float = LR, log_every: int = 0, eval_snr: float = 10.0,
|
||||
eval_frames: int = 20000, seed: int = SEED):
|
||||
"""Digit-wise cross entropy under superposition; cost independent of V."""
|
||||
set_seed(seed)
|
||||
model.to(DEVICE)
|
||||
opt = torch.optim.Adam(model.parameters(), lr=lr)
|
||||
ce = torch.nn.CrossEntropyLoss()
|
||||
curve = []
|
||||
t0 = time.time()
|
||||
for it in range(1, iters + 1):
|
||||
digits = torch.randint(model.vu, (batch, model.users, model.P), device=DEVICE)
|
||||
snr = torch.empty(batch).uniform_(*TRAIN_SNR_DB)
|
||||
model.calibrate_power(8192)
|
||||
scores = model(digits, snr) * model.logit_scale.exp()
|
||||
loss = ce(scores.reshape(-1, model.vu), digits.reshape(-1))
|
||||
opt.zero_grad(); loss.backward(); opt.step()
|
||||
if log_every and (it % log_every == 0 or it == 1):
|
||||
ser = eval_ser_sse(model, [eval_snr], frames=eval_frames)[0]
|
||||
curve.append((it, time.time() - t0, float(loss.detach()), ser))
|
||||
model.calibrate_power()
|
||||
return curve
|
||||
|
||||
|
||||
def train_full(model: FullCodebook, iters: int = TRAIN_ITERS,
|
||||
batch: int = TRAIN_BATCH, lr: float = LR, log_every: int = 0,
|
||||
eval_snr: float = 10.0, eval_frames: int = 20000,
|
||||
seed: int = SEED):
|
||||
set_seed(seed)
|
||||
model.to(DEVICE)
|
||||
opt = torch.optim.Adam(model.parameters(), lr=lr)
|
||||
ce = torch.nn.CrossEntropyLoss()
|
||||
curve = []
|
||||
t0 = time.time()
|
||||
for it in range(1, iters + 1):
|
||||
idx = torch.randint(model.Vc, (batch, model.users), device=DEVICE)
|
||||
snr = torch.empty(batch).uniform_(*TRAIN_SNR_DB)
|
||||
model.calibrate_power(8192)
|
||||
scores = model(idx, snr) * model.logit_scale.exp()
|
||||
loss = ce(scores.reshape(-1, model.Vc), idx.reshape(-1))
|
||||
opt.zero_grad(); loss.backward(); opt.step()
|
||||
if log_every and (it % log_every == 0 or it == 1):
|
||||
ser = eval_ser_full(model, [eval_snr], frames=eval_frames)[0]
|
||||
curve.append((it, time.time() - t0, float(loss.detach()), ser))
|
||||
model.calibrate_power()
|
||||
return curve
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# evaluation (Monte Carlo)
|
||||
# ----------------------------------------------------------------------
|
||||
@torch.no_grad()
|
||||
def eval_ser_sse(model: SSE, snr_list, frames: int = 2_000_000,
|
||||
chunk: int = 100_000, seed: int = 777):
|
||||
"""Frame (vocabulary-symbol) error rate: any wrong digit is an error."""
|
||||
model.eval().to(DEVICE)
|
||||
out = []
|
||||
for snr_db in snr_list:
|
||||
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
|
||||
err = tot = 0
|
||||
for n0 in range(0, frames, chunk):
|
||||
n = min(chunk, frames - n0)
|
||||
digits = torch.randint(model.vu, (n, model.users, model.P),
|
||||
generator=g).to(DEVICE)
|
||||
snr = torch.full((n,), float(snr_db))
|
||||
scores = model(digits, snr)
|
||||
wrong = (scores.argmax(-1) != digits).any(dim=2) # (n,U)
|
||||
err += int(wrong.sum()); tot += n * model.users
|
||||
out.append(err / tot)
|
||||
return out
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def eval_ser_full(model: FullCodebook, snr_list, frames: int = 2_000_000,
|
||||
chunk: int = 50_000, seed: int = 777):
|
||||
model.eval().to(DEVICE)
|
||||
if model.Vc >= 4096:
|
||||
chunk = max(512, (1 << 23) // model.Vc)
|
||||
out = []
|
||||
for snr_db in snr_list:
|
||||
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
|
||||
err = tot = 0
|
||||
for n0 in range(0, frames, chunk):
|
||||
n = min(chunk, frames - n0)
|
||||
idx = torch.randint(model.Vc, (n, model.users), generator=g).to(DEVICE)
|
||||
snr = torch.full((n,), float(snr_db))
|
||||
scores = model(idx, snr)
|
||||
err += int((scores.argmax(-1) != idx).sum()); tot += n * model.users
|
||||
out.append(err / tot)
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# conventional digital scheme: OMA with BPSK per real dimension (QPSK
|
||||
# per complex subcarrier under the declared real-imaginary stacking)
|
||||
# ----------------------------------------------------------------------
|
||||
def oma_ser(snr_db_list, bits: int = 16, n_grid: int = 200_000):
|
||||
"""Semi-analytic expression under the real-dimension convention.
|
||||
All bits of a vocabulary symbol share the same flat-fading gain, so
|
||||
SER = E_h[1 - (1 - Q(h sqrt(snr)))^bits] with h^2 ~ Exp(1); the
|
||||
expectation is evaluated by numerical integration on a dense grid."""
|
||||
x = (np.arange(n_grid) + 0.5) / n_grid # uniform quantiles
|
||||
h = np.sqrt(-np.log(1.0 - x)) # inverse-CDF transform
|
||||
out = []
|
||||
for s in snr_db_list:
|
||||
g = 10.0 ** (s / 10.0)
|
||||
q = 0.5 * np.array([math.erfc(v / math.sqrt(2.0)) for v in
|
||||
np.clip(h * math.sqrt(g), 0, 38)])
|
||||
out.append(float(np.mean(1.0 - (1.0 - q) ** bits)))
|
||||
return out
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def oma_ser_mc(snr_db_list, bits: int = 16, frames: int = 2_000_000,
|
||||
chunk: int = 200_000, seed: int = 777):
|
||||
"""Monte Carlo check of the closed form (same channel conventions)."""
|
||||
out = []
|
||||
for s in snr_db_list:
|
||||
sigma = math.sqrt(1.0 / (10.0 ** (s / 10.0))) # per-dim, unit Es
|
||||
err = tot = 0
|
||||
torch.manual_seed(seed + int(10 * s))
|
||||
for n0 in range(0, frames, chunk):
|
||||
n = min(chunk, frames - n0)
|
||||
h = rayleigh_gain((n, 1))
|
||||
b = torch.randint(0, 2, (n, bits), device=DEVICE) * 2.0 - 1.0
|
||||
y = h * b + sigma * torch.randn(n, bits, device=DEVICE)
|
||||
wrong = ((y * b) < 0).any(dim=1)
|
||||
err += int(wrong.sum()); tot += n
|
||||
out.append(err / tot)
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# analysis: union bound with residual-interference Gaussian approximation
|
||||
# ----------------------------------------------------------------------
|
||||
@torch.no_grad()
|
||||
def sse_union_bound(model: SSE, snr_db_list, n_mc_h: int = 200_000):
|
||||
"""P_e <= 1 - (1 - min(1, sum_pairs)) ... digit union bound averaged
|
||||
over the Rayleigh gain, with measured residual interference treated
|
||||
as additional Gaussian noise (approximation stated in the paper)."""
|
||||
model.eval().to(DEVICE)
|
||||
Bn = model.unit_codebook()
|
||||
m = model.masks()
|
||||
c = float(model.c)
|
||||
# residual interference power per dimension at user u (measured)
|
||||
digits = torch.randint(model.vu, (65536, model.users, model.P), device=DEVICE)
|
||||
e = Bn[digits] / math.sqrt(model.P)
|
||||
x = e * m[None, :, None, :] # (N,U,P,L)
|
||||
y = x.sum(dim=1) / c # (N,P,L)
|
||||
# per-user: signal = x_u/c, interference = (y - x_u/c)
|
||||
interf_pw = []
|
||||
dmin2 = []
|
||||
for u in range(model.users):
|
||||
su = x[:, u] / c # (N,P,L)
|
||||
iu = y - su
|
||||
# project interference onto the normalized candidate directions
|
||||
cand = Bn * m[u][None, :] # (Vu,L)
|
||||
cn = cand / cand.norm(dim=1, keepdim=True).clamp_min(1e-8)
|
||||
proj = torch.einsum("npl,vl->npv", iu, cn)
|
||||
interf_pw.append(float(proj.pow(2).mean()))
|
||||
# pairwise distances of the scaled candidate set (tx side scaling)
|
||||
cs = cand / (c * math.sqrt(model.P))
|
||||
dd = torch.cdist(cs, cs)
|
||||
dmin2.append(float((dd + torch.eye(model.vu, device=DEVICE) * 1e9).min() ** 2))
|
||||
res = []
|
||||
g = rayleigh_gain(n_mc_h)
|
||||
for s in snr_db_list:
|
||||
sig2 = float(snr_to_sigma2(torch.tensor(s)))
|
||||
pe_users = []
|
||||
for u in range(model.users):
|
||||
# effective noise per dim after equalization: sig2/h^2 + interf
|
||||
sig_eff2 = sig2 / g.pow(2) + interf_pw[u]
|
||||
arg = torch.sqrt(torch.clamp(torch.tensor(dmin2[u], device=DEVICE)
|
||||
/ (4.0 * sig_eff2), min=0.0))
|
||||
q = 0.5 * torch.erfc(arg / math.sqrt(2.0))
|
||||
p_digit = torch.clamp((model.vu - 1) * q, max=1.0)
|
||||
p_frame = 1.0 - (1.0 - p_digit) ** model.P
|
||||
pe_users.append(float(p_frame.mean()))
|
||||
res.append(sum(pe_users) / len(pe_users))
|
||||
return res
|
||||
|
||||
|
||||
def write_csv(path: Path, header: list[str], rows) -> None:
|
||||
with open(path, "w") as f:
|
||||
f.write(",".join(header) + "\n")
|
||||
for row in rows:
|
||||
f.write(",".join(f"{v:.10g}" if isinstance(v, float) else str(v)
|
||||
for v in row) + "\n")
|
||||
print("[csv]", path)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Numerical verification of the closed forms in Sections IV and V of
|
||||
paper 11 (mask-as-key encryption and jamming robustness).
|
||||
|
||||
Every claim that enters the manuscript is checked here against Monte
|
||||
Carlo, with a PASS/FAIL verdict and the achieved agreement level printed.
|
||||
Real-vector convention, dimension d, U users, codebook of V unit-norm
|
||||
codewords, per-user masks with zero-mean entries normalized to
|
||||
||m||^2 = d (so E[m_k^2] = 1).
|
||||
|
||||
Notation matches the tex:
|
||||
y = (1/c) sum_u e_{s_u} .* m_u transmit frame
|
||||
legit score z_{u,i} = r_u^T (e_i .* m_u), r_u = y + n_u/h_u
|
||||
eve score zE_{u,i} = (y_E/h_E)^T (e_i .* mtil_u)
|
||||
jammer adds h_J sqrt(rho) w to the victim observation
|
||||
|
||||
Run on CPU (NumPy); no training involved, pure algebra checks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
|
||||
RNG = np.random.default_rng(2026)
|
||||
D, U, V = 64, 4, 256
|
||||
|
||||
|
||||
def unit_codebook(V, d, rng):
|
||||
E = rng.standard_normal((V, d))
|
||||
return E / np.linalg.norm(E, axis=1, keepdims=True)
|
||||
|
||||
|
||||
def masks(U, d, rng):
|
||||
"""Zero-mean entries normalized so ||m_u||^2 = d."""
|
||||
M = rng.standard_normal((U, d))
|
||||
return M / np.linalg.norm(M, axis=1, keepdims=True) * np.sqrt(d)
|
||||
|
||||
|
||||
def report(tag, claim, emp, tol, extra=""):
|
||||
err = abs(claim - emp)
|
||||
ok = err <= tol
|
||||
print(f"[{'PASS' if ok else 'FAIL'}] {tag}: claim={claim:.5g} "
|
||||
f"emp={emp:.5g} |err|={err:.2g} tol={tol:g} {extra}")
|
||||
return ok
|
||||
|
||||
|
||||
def v1_legit_self_alignment():
|
||||
"""Claim: E[e_s^T diag(m^2) e_s] = 1 (signal self-correlation)."""
|
||||
E = unit_codebook(V, D, RNG)
|
||||
vals = []
|
||||
for _ in range(4000):
|
||||
m = masks(1, D, RNG)[0]
|
||||
s = RNG.integers(V)
|
||||
vals.append(float((E[s] ** 2) @ (m ** 2)))
|
||||
return report("V1 legit self-alignment", 1.0, float(np.mean(vals)), 2e-2)
|
||||
|
||||
|
||||
def v2_eve_uninformed():
|
||||
"""Claim: with an independent substitute mask, the eavesdropper's
|
||||
correct-index correlation has the same mean as any wrong index, so
|
||||
the mean advantage is zero and the eavesdropper SER = (V-1)/V,
|
||||
independent of SNR."""
|
||||
E = unit_codebook(V, D, RNG)
|
||||
# mean advantage of the true index over the wrong indices, noiseless
|
||||
adv = []
|
||||
ser_by_snr = {}
|
||||
for snr_db in [0.0, 10.0, 20.0, 80.0]: # 80 dB stands in for noiseless
|
||||
sigma = np.sqrt(1.0 / (D * 10 ** (snr_db / 10.0)))
|
||||
err = 0
|
||||
trials = 6000
|
||||
for _ in range(trials):
|
||||
s = RNG.integers(V, size=U)
|
||||
M = masks(U, D, RNG)
|
||||
c = 1.0 # scale-invariant for argmax
|
||||
y = np.zeros(D)
|
||||
for u in range(U):
|
||||
y += E[s[u]] * M[u]
|
||||
y /= np.sqrt(U) # any fixed scale
|
||||
# eavesdropper targets user 0 with an independent wrong mask
|
||||
mtil = masks(1, D, RNG)[0]
|
||||
hE = np.sqrt(-np.log(RNG.random()))
|
||||
rE = y + (sigma / hE) * RNG.standard_normal(D)
|
||||
scores = (E * mtil) @ rE # (V,)
|
||||
if snr_db == 80.0:
|
||||
adv.append(scores[s[0]] - scores.mean())
|
||||
if scores.argmax() != s[0]:
|
||||
err += 1
|
||||
ser_by_snr[snr_db] = err / trials
|
||||
chance = (V - 1) / V
|
||||
ok1 = report("V2a eve mean advantage", 0.0, float(np.mean(adv)), 3e-3)
|
||||
ok2 = True
|
||||
for snr_db, ser in ser_by_snr.items():
|
||||
tag = f"V2b eve SER @ {int(snr_db)}dB"
|
||||
ok2 &= report(tag, chance, ser, 1.5e-2)
|
||||
return ok1 and ok2
|
||||
|
||||
|
||||
def v3_leakage_vs_correlation():
|
||||
"""Claim: if the substitute mask has normalized correlation
|
||||
rho = <m,mtil>/d with the true mask, the eavesdropper's true-index
|
||||
bias grows linearly in rho; independent random masks give
|
||||
E|rho| = O(1/sqrt(d)); orthogonal masks give rho = 0."""
|
||||
E = unit_codebook(V, D, RNG)
|
||||
# (a) bias vs prescribed rho
|
||||
slopes = []
|
||||
for rho in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
bias = []
|
||||
for _ in range(3000):
|
||||
m = masks(1, D, RNG)[0]
|
||||
mp = masks(1, D, RNG)[0]
|
||||
mp = mp - (mp @ m) / (m @ m) * m # orthogonalize
|
||||
mp = mp / np.linalg.norm(mp) * np.sqrt(D)
|
||||
mtil = rho * m + np.sqrt(1 - rho ** 2) * mp
|
||||
s = RNG.integers(V)
|
||||
# noiseless single-user useful alignment for the true index
|
||||
bias.append(float((E[s] ** 2) @ (m * mtil)))
|
||||
slopes.append((rho, float(np.mean(bias))))
|
||||
# claim: bias(rho) = rho * bias(1); check linearity
|
||||
b1 = slopes[-1][1]
|
||||
lin_ok = all(abs(b - rho * b1) <= 3e-2 for rho, b in slopes)
|
||||
print(f"[{'PASS' if lin_ok else 'FAIL'}] V3a bias linear in rho: "
|
||||
+ ", ".join(f"rho={r:.2f}->{b:.3f}" for r, b in slopes))
|
||||
# (b) random independent mask correlation: E|corr| = sqrt(2/(pi d))
|
||||
# (the folded-normal mean of a N(0, 1/d) variable)
|
||||
corrs = []
|
||||
for _ in range(5000):
|
||||
m = masks(1, D, RNG)[0]
|
||||
mt = masks(1, D, RNG)[0]
|
||||
corrs.append(abs((m @ mt) / D))
|
||||
emp = float(np.mean(corrs))
|
||||
claim = float(np.sqrt(2.0 / (np.pi * D)))
|
||||
ok_b = report("V3b random mask E|corr|", claim, emp, 0.1 * claim)
|
||||
return lin_ok and ok_b
|
||||
|
||||
|
||||
def v4_blind_jammer_spread():
|
||||
"""Claim: a mask-blind jammer (w independent of m_u) contributes a
|
||||
zero-mean term to every candidate score with variance
|
||||
(hJ^2/hu^2) rho * sum_k w_k^2 e_{i,k}^2, i.e. it is spread with no
|
||||
systematic bias toward any index."""
|
||||
E = unit_codebook(V, D, RNG)
|
||||
rho = 1.0
|
||||
# (a) structural claim: the mask projection g_i = (w .* m)^T e_i is
|
||||
# zero-mean, decoupled from the positive gain factor hJ/hu.
|
||||
g, var_emp, var_cl = [], [], []
|
||||
for _ in range(20000):
|
||||
m = masks(1, D, RNG)[0]
|
||||
w = RNG.standard_normal(D); w /= np.linalg.norm(w)
|
||||
i = RNG.integers(V)
|
||||
gi = (w * m) @ E[i]
|
||||
g.append(gi)
|
||||
var_emp.append(gi ** 2)
|
||||
var_cl.append(np.sum(w ** 2 * E[i] ** 2))
|
||||
ok1 = report("V4a blind jammer projection mean", 0.0,
|
||||
float(np.mean(g)), 3e-3)
|
||||
# (b) variance of the projection matches sum_k w_k^2 e_{i,k}^2; the
|
||||
# full contribution scales this by (hJ^2/hu^2) rho.
|
||||
ok2 = report("V4b blind jammer projection variance",
|
||||
float(np.mean(var_cl)), float(np.mean(var_emp)),
|
||||
0.05 * float(np.mean(var_cl)))
|
||||
return ok1 and ok2
|
||||
|
||||
|
||||
def v5_matched_jammer_concentrates():
|
||||
"""Claim: a mask-matched jammer aligned with the victim key for a
|
||||
target index t creates a bias of order one on index t, while the
|
||||
blind-jammer projection has zero mean and RMS of order 1/sqrt(d).
|
||||
The physically meaningful separation is matched bias over blind RMS,
|
||||
which is sqrt(d) (the sample mean of the blind bias estimates zero
|
||||
and is pure Monte Carlo noise, so it is NOT a valid denominator)."""
|
||||
E = unit_codebook(V, D, RNG)
|
||||
bias_matched, blind_sq = [], []
|
||||
for _ in range(3000):
|
||||
m = masks(1, D, RNG)[0]
|
||||
t = RNG.integers(V)
|
||||
wm = E[t] * m; wm /= np.linalg.norm(wm) # matched (needs m)
|
||||
wb = RNG.standard_normal(D); wb /= np.linalg.norm(wb) # blind
|
||||
bias_matched.append(float((wm * m) @ E[t]))
|
||||
blind_sq.append(float(((wb * m) @ E[t]) ** 2))
|
||||
bm = float(np.mean(bias_matched))
|
||||
brms = float(np.sqrt(np.mean(blind_sq)))
|
||||
ratio = bm / brms
|
||||
ok = abs(ratio - np.sqrt(D)) <= 0.25 * np.sqrt(D) and bm > 0.9
|
||||
print(f"[{'PASS' if ok else 'FAIL'}] V5 matched bias / blind RMS: "
|
||||
f"matched={bm:.3f} blind_rms={brms:.4f} ratio={ratio:.1f} "
|
||||
f"(claim sqrt(d)={np.sqrt(D):.1f})")
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
print(f"config d={D} U={U} V={V}\n")
|
||||
results = {
|
||||
"V1": v1_legit_self_alignment(),
|
||||
"V2": v2_eve_uninformed(),
|
||||
"V3": v3_leakage_vs_correlation(),
|
||||
"V4": v4_blind_jammer_spread(),
|
||||
"V5": v5_matched_jammer_concentrates(),
|
||||
}
|
||||
print("\nsummary:", {k: ("PASS" if v else "FAIL") for k, v in results.items()})
|
||||
print("ALL PASS" if all(results.values()) else "SOME FAILED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
snr_db,legit,eve_wrong,eve_none,eve_avg,chance
|
||||
0,0.55692125,0.995261875,0.992021875,0.95962875,0.99609375
|
||||
5,0.26456,0.9946975,0.990336875,0.94351625,0.99609375
|
||||
10,0.099379375,0.994283125,0.989521875,0.93188,0.99609375
|
||||
15,0.0334,0.994011875,0.989031875,0.925765625,0.99609375
|
||||
20,0.010784375,0.9939975,0.988625625,0.922928125,0.99609375
|
||||
|
@@ -0,0 +1,6 @@
|
||||
L,d,legit_ser,eve_ser,mask_xcorr
|
||||
8,16,0.5546,0.9975366667,0.07264538854
|
||||
16,32,0.1181416667,0.99724,0.006702715065
|
||||
32,64,0.09873416667,0.9943241667,0.01185981557
|
||||
64,128,0.08845333333,0.9922383333,0.009370678104
|
||||
128,256,0.08771083333,0.9851966667,0.008488235064
|
||||
|
@@ -0,0 +1,8 @@
|
||||
jsr_db,ser_aligned,ser_random
|
||||
-10,0.1924166667,0.1780558333
|
||||
-5,0.2540891667,0.2948208333
|
||||
0,0.3063416667,0.4912158333
|
||||
5,0.3530991667,0.7022516667
|
||||
10,0.4284433333,0.8538791667
|
||||
15,0.5548433333,0.935385
|
||||
20,0.71272,0.9718341667
|
||||
|
@@ -0,0 +1,43 @@
|
||||
snr_db,n_frames,kappa,eve_ser
|
||||
0,1,0.2374871574,0.99874975
|
||||
0,2,0.6126027606,0.96312825
|
||||
0,3,0.7285515711,0.921628875
|
||||
0,4,0.8244251639,0.85221925
|
||||
0,5,0.868628718,0.797125
|
||||
0,6,0.8920436099,0.745713875
|
||||
0,8,0.9208301157,0.67688775
|
||||
0,10,0.9429872826,0.592138625
|
||||
0,12,0.9557282105,0.534421875
|
||||
0,16,0.9700051412,0.44833975
|
||||
0,24,0.9802580416,0.38197025
|
||||
0,32,0.9852379695,0.351050875
|
||||
0,48,0.9904022858,0.32166525
|
||||
0,64,0.993118532,0.307348
|
||||
10,1,0.3489475794,0.989224875
|
||||
10,2,0.8794977516,0.704872
|
||||
10,3,0.9473693997,0.547376875
|
||||
10,4,0.9682661489,0.455776875
|
||||
10,5,0.9782963678,0.3962285
|
||||
10,6,0.9843041778,0.359232
|
||||
10,8,0.9900029436,0.326613375
|
||||
10,10,0.9934537426,0.306601375
|
||||
10,12,0.9949941516,0.2992645
|
||||
10,16,0.9965374678,0.291205125
|
||||
10,24,0.9978871465,0.285018125
|
||||
10,32,0.9984912023,0.28255425
|
||||
10,48,0.9990138412,0.280105125
|
||||
10,64,0.9992718786,0.279036875
|
||||
20,1,0.596763967,0.861350125
|
||||
20,2,0.984469898,0.34732075
|
||||
20,3,0.9948319912,0.300176375
|
||||
20,4,0.9975389287,0.286598125
|
||||
20,5,0.9984581739,0.282405125
|
||||
20,6,0.9988854468,0.2803655
|
||||
20,8,0.9992000297,0.279362875
|
||||
20,10,0.9994319767,0.278179625
|
||||
20,12,0.9995499209,0.277694875
|
||||
20,16,0.9996712342,0.277181875
|
||||
20,24,0.9997934118,0.276410875
|
||||
20,32,0.999845539,0.2763615
|
||||
20,48,0.9999018267,0.276363875
|
||||
20,64,0.9999270439,0.276194125
|
||||
|
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"vocab_size": 30522,
|
||||
"n_texts": 2000,
|
||||
"frames": 24998,
|
||||
"repeats": 8,
|
||||
"decisions_per_point": 799936,
|
||||
"distinct_tokens": 10486,
|
||||
"max_token_id": 29599,
|
||||
"headlines_scored": 1948,
|
||||
"headline_runs": 4,
|
||||
"recovery": {
|
||||
"20": {
|
||||
"legit": 0.1985369609856263,
|
||||
"eve": 0.0,
|
||||
"insider": 0.0,
|
||||
"oma": 0.19815195071868583
|
||||
},
|
||||
"24": {
|
||||
"legit": 0.5103952772073922,
|
||||
"eve": 0.0,
|
||||
"insider": 0.0,
|
||||
"oma": 0.5160420944558521
|
||||
},
|
||||
"28": {
|
||||
"legit": 0.7630903490759754,
|
||||
"eve": 0.0,
|
||||
"insider": 0.0,
|
||||
"oma": 0.7583418891170431
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
snr_db,ter_legit,ter_eve,ter_insider,ter_oma
|
||||
0,0.8937765021,0.9999699976,0.999212437,0.8927576706
|
||||
4,0.6685672354,0.9999337447,0.9977085667,0.6680284423
|
||||
8,0.3903512281,0.9999224938,0.9958434175,0.3898399372
|
||||
12,0.1875025002,0.9999362449,0.9946258201,0.1874349948
|
||||
16,0.08110023802,0.9999349948,0.9938970118,0.08109648772
|
||||
20,0.03404147332,0.9999337447,0.9936357409,0.03380395432
|
||||
24,0.01362108969,0.9999274942,0.9934882291,0.01364484159
|
||||
28,0.005464187135,0.9999312445,0.993449476,0.005405432435
|
||||
|
@@ -0,0 +1,29 @@
|
||||
L,K,best_rho,eve_ser
|
||||
8,1,0.2918601623,0.9961626487
|
||||
8,10,0.6307837307,0.9703819549
|
||||
8,100,0.8190062809,0.8850038404
|
||||
8,1000,0.9077793813,0.7200369393
|
||||
8,10000,0.9535904264,0.5318095675
|
||||
8,100000,0.9757162716,0.4169265965
|
||||
8,1000000,0.9872786315,0.339091505
|
||||
16,1,0.2228791779,0.9991641146
|
||||
16,10,0.4570522499,0.9957471368
|
||||
16,100,0.6321070191,0.9833336645
|
||||
16,1000,0.7417848118,0.9546712761
|
||||
16,10000,0.8164950053,0.9000406219
|
||||
16,100000,0.8673534005,0.8378011935
|
||||
16,1000000,0.9048131336,0.7494679145
|
||||
32,1,0.1399813941,0.9997996279
|
||||
32,10,0.3205750013,0.9991500426
|
||||
32,100,0.4660256564,0.9971309056
|
||||
32,1000,0.5620279439,0.9934165308
|
||||
32,10000,0.6393936736,0.9852959659
|
||||
32,100000,0.6993164916,0.9746142481
|
||||
32,1000000,0.7479539255,0.9565757219
|
||||
64,1,0.0987436915,0.9998965347
|
||||
64,10,0.2263401688,0.9996584104
|
||||
64,100,0.3389944824,0.9991447783
|
||||
64,1000,0.4155608514,0.9984268536
|
||||
64,10000,0.4757575011,0.9974241507
|
||||
64,100000,0.5299797378,0.9957096073
|
||||
64,1000000,0.5741312045,0.9933056882
|
||||
|
@@ -0,0 +1,6 @@
|
||||
scheme,legit_ser,eve_out,eve_in,jam0_ser
|
||||
proposed,0.303585,0.99983,0.99998,0.80612
|
||||
public_mask,0.303585,0.303585,0.303585,0.95359
|
||||
perm_key,0.303435,0.9999775,0.303435,0.8065225
|
||||
index_cipher,0.303585,0.9999847412,0.9999847412,0.95359
|
||||
oma_plain,0.2747696909,0.2747696909,0.2747696909,nan
|
||||
|
@@ -0,0 +1,8 @@
|
||||
jsr_db,blind,matched,nojam
|
||||
-10,0.468186,0.720358,0.302716
|
||||
-5,0.633112,0.874476,0.302716
|
||||
0,0.806548,0.9534,0.302716
|
||||
5,0.918778,0.984432,0.302716
|
||||
10,0.970874,0.994914,0.302716
|
||||
15,0.989928,0.9984,0.302716
|
||||
20,0.996858,0.999468,0.302716
|
||||
|
@@ -0,0 +1,10 @@
|
||||
L,d,legit_ser,eve_ser,mask_xcorr,oma
|
||||
4,16,0.9997925,0.999963,0.01188752614,0.2747696909
|
||||
6,24,0.9921175,0.9996935,0.09415384382,0.2747696909
|
||||
8,32,0.9297855,0.999972,0.007307400461,0.2747696909
|
||||
12,48,0.416604,0.999781,0.005153660662,0.2747696909
|
||||
16,64,0.2762895,0.9999285,0.007116591092,0.2747696909
|
||||
24,96,0.1829615,0.999975,0.002973971656,0.2747696909
|
||||
32,128,0.131901,0.9998895,0.005575809628,0.2747696909
|
||||
48,192,0.090206,0.999845,0.005743456539,0.2747696909
|
||||
64,256,0.0635265,0.9997915,0.006678360514,0.2747696909
|
||||
|
@@ -0,0 +1,5 @@
|
||||
family,legit_ser,eve_ser,mask_xcorr
|
||||
random,0.64962,0.998488,0.2709003091
|
||||
hadamard,0.2568,0.9995605,0
|
||||
learned,0.2762895,0.9999285,0.007116591092
|
||||
learned_reg,0.315952,0.9999555,0.01121100038
|
||||
|
@@ -0,0 +1,8 @@
|
||||
jsr_db,plain,regularized
|
||||
-10,0.46879,0.4691633333
|
||||
-5,0.6335366667,0.6307333333
|
||||
0,0.8056466667,0.8018166667
|
||||
5,0.9187366667,0.91602
|
||||
10,0.9708533333,0.9693866667
|
||||
15,0.9902133333,0.9896433333
|
||||
20,0.9967333333,0.9965933333
|
||||
|
@@ -0,0 +1,23 @@
|
||||
rho,eve_ser
|
||||
0,0.99997875
|
||||
0.1,0.999955
|
||||
0.2,0.99976375
|
||||
0.3,0.99947625
|
||||
0.4,0.99873
|
||||
0.5,0.99717375
|
||||
0.6,0.992395
|
||||
0.65,0.983195
|
||||
0.7,0.9776825
|
||||
0.75,0.9594925
|
||||
0.8,0.91810625
|
||||
0.84,0.88158125
|
||||
0.88,0.8247575
|
||||
0.9,0.792455
|
||||
0.92,0.67340125
|
||||
0.94,0.602535
|
||||
0.96,0.50181875
|
||||
0.97,0.47287375
|
||||
0.98,0.37558375
|
||||
0.99,0.32493625
|
||||
0.995,0.30185625
|
||||
1,0.27494375
|
||||
|
@@ -0,0 +1,7 @@
|
||||
snr_db,legit,eve_wrong,eve_none,eve_public,oma,chance
|
||||
0,0.8944596875,0.9999515625,0.999990625,0.8943865625,0.8933480658,0.9999847412
|
||||
4,0.66980875,0.9999575,0.99998625,0.670624375,0.6686275787,0.9999847412
|
||||
8,0.39054875,0.9999425,0.9999865625,0.3902384375,0.3892153151,0.9999847412
|
||||
12,0.1880821875,0.9999284375,0.9999890625,0.1877565625,0.1870712987,0.9999847412
|
||||
16,0.0811665625,0.9999203125,0.9999875,0.081488125,0.08092517452,0.9999847412
|
||||
20,0.033519375,0.999921875,0.999988125,0.0334946875,0.03334949917,0.9999847412
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
torch>=2.0
|
||||
numpy>=1.24
|
||||
matplotlib>=3.7
|
||||
# real-token stage only
|
||||
datasets>=2.14
|
||||
transformers>=4.30
|
||||
Reference in New Issue
Block a user