Simulation code and data for the TMC submission

This commit is contained in:
2026-07-27 13:44:54 +09:00
commit 0229c026dc
27 changed files with 3530 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
"""Mask-realization check: the paper's experiments generate the
matched-filter outputs from the EXPECTED cross-Gram model
(semantic_mac.matched_filter). This script draws actual Haar-mixture
masks, passes the physical superposition r = sum_v h_v M_v x_v + n
through the realized masks, applies the same receivers, and compares
the SER against the expectation-model front end on identical latents,
gains, and noise seeds.
The realized per-entry Gram deviation is O(1/sqrt(d)); this check
quantifies its end-to-end effect at d=64 (theory/mobility setting) and
d=768 (real-embedding setting).
Outputs: data/e_mask_check.csv
"""
import math
import os
import numpy as np
from semantic_mac import (affinity_matrix, matched_filter, demux_sr,
demux_sc, demux_lmmse, demux_dr,
sample_latents_isotropic, random_orthogonal,
metrics)
HERE = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(HERE, "..", "data")
U = 4
BETA = 0.5
def haar_masks(B, d, rng):
"""M_u = sum_k A_uk U_k with A = chol(B), U_k independent Haar."""
A = np.linalg.cholesky(B)
Us = [random_orthogonal(d, rng) for _ in range(U)]
return [sum(A[u, k] * Us[k] for k in range(U)) for u in range(U)]
def physical_front_end(z, h, Ms, rho, rng):
"""r = sum_v h_v M_v z_v + n (ambient AWGN), tilde_u = M_u^T r / h_u."""
batch, Uu, d = z.shape
sigma = math.sqrt(1.0 / rho)
r = np.einsum('bv,vde,bve->bd', h,
np.stack(Ms), z) + rng.standard_normal((batch, d)) * sigma
tilde = np.stack([(r @ Ms[u]) / h[:, u][:, None] for u in range(U)],
axis=1)
return tilde
def run(d, d_c, batch, n_mask_draws, snr_db):
RHO = 10 ** (snr_db / 10)
a = math.sqrt(BETA) * np.ones(U)
B = affinity_matrix(a)
Vc = np.eye(d)[:, :d_c]
diffs = {m: [] for m in ("SR", "SC", "LMMSE", "DR")}
sers_r = {m: [] for m in diffs}
sers_e = {m: [] for m in diffs}
for t in range(n_mask_draws):
rng = np.random.default_rng(7700 + 10 * t)
z = sample_latents_isotropic(batch, U, d, d_c, a, rng)
hc = (rng.standard_normal((batch, U)) +
1j * rng.standard_normal((batch, U))) / math.sqrt(2)
h = np.clip(np.abs(hc), 0.2, None)
Ms = haar_masks(B, d, rng)
gram_dev = max(np.abs(Ms[u].T @ Ms[v] - B[u, v] * np.eye(d)).max()
for u in range(U) for v in range(U))
tilde_r = physical_front_end(z, h, Ms, RHO, rng)
# expectation-model front end on the SAME latents and gains
rng_e = np.random.default_rng(8800 + 10 * t)
tilde_e = np.einsum('uv,bvd,bv,bu->bud', B, z, h, 1.0 / h)
xi = rng_e.standard_normal((batch, U, d)) * math.sqrt(1.0 / RHO)
A = np.linalg.cholesky(B)
for b in range(batch):
tilde_e[b] += (A @ xi[b]) / h[b][:, None]
for name, tl in (("realized", tilde_r), ("expected", tilde_e)):
out = {}
out["SR"] = demux_sr(tl, B, h)
out["SC"] = demux_sc(tl, h)
out["LMMSE"], _ = demux_lmmse(tl, B, h, RHO)
out["DR"] = demux_dr(tl, B, h, RHO, a, Vc)
for m in diffs:
_, _, s = metrics(out[m], z)
(sers_r if name == "realized" else sers_e)[m].append(s)
for m in diffs:
diffs[m].append(sers_r[m][-1] - sers_e[m][-1])
print(f"d={d} draw {t+1}/{n_mask_draws} gram_dev={gram_dev:.3f} "
+ " ".join(f"{m}:d={diffs[m][-1]:+.4f}" for m in diffs))
return {m: (float(np.mean(sers_r[m])), float(np.mean(sers_e[m])),
float(np.mean(diffs[m])), float(np.std(diffs[m])))
for m in diffs}
def main():
rows = []
for d, d_c, batch, draws, snr_db in ((64, 16, 2000, 12, 10),
(768, 128, 400, 6, 20)):
res = run(d, d_c, batch, draws, snr_db)
for m, (sr, se, md, sd) in res.items():
rows.append((d, snr_db, m, sr, se, md, sd))
print(f"d={d} snr={snr_db} {m}: realized={sr:.4f} "
f"expected={se:.4f} mean_diff={md:+.5f} std={sd:.5f}")
with open(os.path.join(DATA, "e_mask_check.csv"), "w") as f:
f.write("d,snr,method,ser_realized,ser_expected,mean_diff,"
"std_diff\n")
for r in rows:
f.write(",".join(str(x) for x in r) + "\n")
print("mask check done.")
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
"""E1: receiver theory verification (synthetic isotropic contents).
Outputs: data/e1_beta.csv, data/e1_snr.csv, data/e1_endpoints.txt
(figures come from replot_all.py only)
"""
import math
import os
import numpy as np
from semantic_mac import (affinity_matrix, matched_filter, demux_sr, demux_sc,
demux_lmmse, demux_dr, lmmse_matrices,
sample_latents_isotropic, metrics,
oma_observe, demux_noma_genie)
HERE = os.path.dirname(os.path.abspath(__file__))
FIG = os.path.join(HERE, "..", "fig")
DATA = os.path.join(HERE, "..", "data")
os.makedirs(FIG, exist_ok=True)
os.makedirs(DATA, exist_ok=True)
U, D, DC = 4, 64, 16
BATCH = 4000
NAMES = ["OMA", "NOMA", "SR", "SC", "LMMSE", "DR"]
def run_point(beta, rho, rng, conv_seed=0):
a = math.sqrt(beta) * np.ones(U)
B = affinity_matrix(a)
z = sample_latents_isotropic(BATCH, U, D, DC, a, rng)
tilde, h = matched_filter(z, B, rho, rng)
Vc = np.eye(D)[:, :DC]
res = {}
res["SR"] = metrics(demux_sr(tilde, B, h), z)
res["SC"] = metrics(demux_sc(tilde, h), z)
lm, cf = demux_lmmse(tilde, B, h, rho)
res["LMMSE"] = metrics(lm, z)
res["LMMSE_cf"] = float(cf.mean())
# SR closed form: d sigma^2 [B^-1]_uu / h^2 averaged
Binv = np.diag(np.linalg.inv(B))
res["SR_cf"] = float((D / rho) * (Binv[None, :] / h ** 2).mean())
res["DR"] = metrics(demux_dr(tilde, B, h, rho, a, Vc), z)
# conventional baselines on a dedicated stream (keeps main draws intact)
rng_c = np.random.default_rng(90000 + conv_seed)
res["OMA"] = metrics(oma_observe(z, h, rho, rng_c), z)
res["NOMA"] = metrics(demux_noma_genie(z, h, rho, rng_c), z)
return res
def main():
rng = np.random.default_rng(0)
rho_db = 10
rho = 10 ** (rho_db / 10)
betas = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99]
rows = []
for b in betas:
r = run_point(b, rho, rng, conv_seed=int(b * 100))
rows.append(r)
print(f"beta={b:4.2f} " + " ".join(
f"{n}:cos={r[n][0]:.3f},nmse={r[n][1]:.3f},ser={r[n][2]:.3f}"
for n in NAMES))
with open(os.path.join(DATA, "e1_beta.csv"), "w") as f:
f.write("beta," + ",".join(f"{n}_cos,{n}_nmse,{n}_ser" for n in NAMES)
+ ",LMMSE_cf,SR_cf\n")
for b, r in zip(betas, rows):
f.write(f"{b}," + ",".join(
f"{r[n][0]},{r[n][1]},{r[n][2]}" for n in NAMES)
+ f",{r['LMMSE_cf']},{r['SR_cf']}\n")
beta_mid = 0.4
snrs = list(range(0, 21, 4))
rows_s = []
for s in snrs:
r = run_point(beta_mid, 10 ** (s / 10), rng, conv_seed=1000 + s)
rows_s.append(r)
print(f"snr={s} " + " ".join(f"{n}:ser={r[n][2]:.3f}" for n in NAMES))
with open(os.path.join(DATA, "e1_snr.csv"), "w") as f:
f.write("snr," + ",".join(f"{n}_cos,{n}_nmse,{n}_ser" for n in NAMES) + "\n")
for s, r in zip(snrs, rows_s):
f.write(f"{s}," + ",".join(
f"{r[n][0]},{r[n][1]},{r[n][2]}" for n in NAMES) + "\n")
# endpoint checks
lines = []
a = math.sqrt(0.4) * np.ones(U)
B = affinity_matrix(a)
h = np.clip(np.abs((rng.standard_normal(U) + 1j * rng.standard_normal(U))
/ math.sqrt(2)), 0.2, None)
for rdb in (10, 40, 80):
W, _ = lmmse_matrices(B, h, 10 ** (-rdb / 10), D)
Ginv = np.linalg.inv(np.diag(1 / h) @ B @ np.diag(h))
rel = np.linalg.norm(W - Ginv) / np.linalg.norm(Ginv)
lines.append(f"(i) rho={rdb}dB rel_diff_W_vs_Gammainv={rel:.3e}")
W0, _ = lmmse_matrices(np.eye(U), h, 0.1, D)
off = np.abs(W0 - np.diag(np.diag(W0))).max()
wiener = h ** 2 / (h ** 2 + D * 0.1)
lines.append(f"(ii) beta=0 max_offdiag={off:.3e} "
f"max_diag_minus_wiener={np.abs(np.diag(W0)-wiener).max():.3e}")
with open(os.path.join(DATA, "e1_endpoints.txt"), "w") as f:
f.write("\n".join(lines) + "\n")
print("\n".join(lines))
# figures are produced only by the canonical replot_all.py (uniform
# geometry); experiment scripts write CSVs exclusively.
print("E1 done. Run replot_all.py to regenerate the figures.")
if __name__ == "__main__":
main()
+244
View File
@@ -0,0 +1,244 @@
"""E2: embedding-structure optimization on REAL PLM embeddings.
Contents are real BERT AG-News embeddings (8000 x 768, from the published
shared-embedding line); the structured latent is mixed by an unknown random
orthogonal R (the frozen encoder's arbitrary basis). We compare:
(i) LMMSE -- B-aware optimal linear receiver, no structure
(ii) DR + spectral -- closed-form shared-subspace recovery from N pairs
(iii) DR + adapter -- channel-in-the-loop learned linear refinement
(iv) DR + oracle -- true mixing basis (upper bound)
Calibration, adapter training, and subspace/spectrum sweeps draw only from
the first 7000 pool sentences; the performance ladder is evaluated on
tuples drawn from the held-out remaining 1000 sentences.
Outputs: data/e2_subspace_multi.csv, data/e2_spectrum_multi.csv,
data/e2_exponents.txt, data/e2_caliberr.txt, data/e2_ladder.csv
(figures come from replot_all.py only)
"""
import math
import os
import numpy as np
import torch
from semantic_mac import (EmbeddingPool, affinity_matrix, matched_filter,
demux_lmmse, demux_dr, sample_latents_pool,
random_orthogonal, learn_structure_spectral,
subspace_error, metrics,
oma_observe, demux_noma_genie)
HERE = os.path.dirname(os.path.abspath(__file__))
FIG = os.path.join(HERE, "..", "fig")
DATA = os.path.join(HERE, "..", "data")
os.makedirs(FIG, exist_ok=True)
os.makedirs(DATA, exist_ok=True)
# BERT AG-News embedding pool: shipped in data/ for the public release,
# with a fallback to the original location in the paper workspace.
POOL_PT = os.path.join(HERE, "..", "data", "bert_agnews_8000.pt")
if not os.path.exists(POOL_PT):
POOL_PT = os.path.join(HERE, "..", "..", "5. WCL-DRL",
"bert_agnews_8000.pt")
U, D, DC = 4, 768, 128
BETA = 0.5
N_PAIR = 200 # paired calibration samples available to the optimizer
BATCH_EVAL = 4000
N_TRAIN_POOL = 7000 # sentences usable for calibration/adapter training
# evaluation tuples are drawn only from the held-out remainder of the pool
IDX_TRAIN = np.arange(N_TRAIN_POOL)
IDX_EVAL = np.arange(N_TRAIN_POOL, 8000)
def gen_clean(pool, n, a, R, rng, idx_pool=None):
z = sample_latents_pool(pool, n, U, D, DC, a, rng, idx_pool=idx_pool)
return z, z @ R.T
# ----------------------------------------------------------------------
# Learned linear adapter (channel-in-the-loop refinement)
# ----------------------------------------------------------------------
def dr_torch(tilde, B, h, rho, a, W, d_c):
"""Differentiable decomposition receiver in the adapter frame W (d x d).
Returns ambient-frame estimates W^T z_hat."""
Bt, Ut, d = tilde.shape
sigma2 = 1.0 / rho
y = tilde @ W.T # rotated MF outputs
Zs, Zp = y[:, :, :d_c], y[:, :, d_c:]
Hi = torch.diag_embed(1.0 / h)
Hm = torch.diag_embed(h)
Bb = B.unsqueeze(0).expand(Bt, Ut, Ut)
Gamma = Hi @ Bb @ Hm
Cn = sigma2 * (Hi @ Bb @ Hi)
ratio = h.unsqueeze(1) / h.unsqueeze(2) # h_v / h_u
gamma = a.view(1, 1, Ut) * Bb * ratio
gamma = gamma.sum(dim=2) - (a.view(1, Ut) * (Bb.diagonal(dim1=1, dim2=2) - 1.0))
# gamma_u = a_u + sum_{v != u} B_uv a_v h_v/h_u (diagonal of Bb is 1)
Cn_inv = torch.linalg.inv(Cn)
gCg = torch.einsum('bu,buv,bv->b', gamma, Cn_inv, gamma).clamp_min(1e-9)
c_hat = torch.einsum('bu,buv,bvk->bk', gamma, Cn_inv, Zs) / gCg.unsqueeze(-1)
shrink_c = (1.0 / d_c) / (1.0 / d_c + 1.0 / gCg)
c_hat = c_hat * shrink_c.unsqueeze(-1)
Gp = torch.linalg.solve(Gamma, Zp)
Binv_uu = torch.linalg.inv(B).diagonal()
sig_p = (1.0 - a ** 2).clamp_min(1e-9)
err_p = (d - d_c) * sigma2 * Binv_uu.view(1, Ut) / (h ** 2)
shrink_p = sig_p.view(1, Ut) / (sig_p.view(1, Ut) + err_p)
z_hat = torch.cat([
a.view(1, Ut, 1) * c_hat.unsqueeze(1).expand(Bt, Ut, d_c),
shrink_p.unsqueeze(-1) * Gp], dim=2)
return z_hat @ W # back to ambient frame
def train_adapter(x_cal, a, V_init, rng, steps=400, batch=24, lr=3e-4,
lam_orth=1.0, lam_align=0.2, seed=0):
"""Refine the full rotation W (init = spectral basis) through the channel.
x_cal: the SAME N_PAIR calibration embeddings used by the spectral
estimator (the adapter adds no data cost, as stated in the paper)."""
torch.manual_seed(seed)
W = torch.nn.Parameter(torch.tensor(V_init.T, dtype=torch.float32))
a_t = torch.tensor(a, dtype=torch.float32)
B_np = affinity_matrix(a)
B_t = torch.tensor(B_np, dtype=torch.float32)
opt = torch.optim.Adam([W], lr=lr)
n_cal = x_cal.shape[0]
x_cal_t = torch.tensor(x_cal, dtype=torch.float32)
for it in range(steps):
idx = torch.randint(0, n_cal, (batch,))
x = x_cal_t[idx]
snr_db = float(rng.uniform(0, 20))
rho = 10 ** (snr_db / 10)
tilde_np, h_np = matched_filter(x.numpy().astype(np.float64),
B_np, rho, rng)
tilde = torch.tensor(tilde_np, dtype=torch.float32)
h = torch.tensor(h_np, dtype=torch.float32)
x_hat = dr_torch(tilde, B_t, h, rho, a_t, W, DC)
cosd = 1.0 - torch.nn.functional.cosine_similarity(
x_hat, x, dim=2).mean()
y = x @ W.T
s = torch.nn.functional.normalize(y[:, :, :DC], dim=2)
align = (s.unsqueeze(1) - s.unsqueeze(2)).pow(2).sum(-1).mean()
orth = (W @ W.T - torch.eye(D)).pow(2).mean()
loss = cosd + lam_align * align + lam_orth * orth
opt.zero_grad()
loss.backward()
opt.step()
if (it + 1) % 100 == 0:
print(f" adapter step {it+1}: loss={loss.item():.4f} "
f"cosd={cosd.item():.4f} align={align.item():.4f}")
with torch.no_grad():
# re-orthonormalize
Uo, _, Vo = torch.linalg.svd(W)
Wo = Uo @ Vo
return Wo.numpy().astype(np.float64)
def main():
rng = np.random.default_rng(7)
obj = torch.load(POOL_PT, map_location="cpu")
X = obj.numpy() if torch.is_tensor(obj) else np.asarray(obj)
pool = EmbeddingPool(X)
print(f"pool: {pool.N} x {pool.d}")
a = math.sqrt(BETA) * np.ones(U)
B = affinity_matrix(a)
R = random_orthogonal(D, rng)
V_true = R[:, :DC]
# --- subspace recovery vs N for multiple user counts ------------------
# Multi-trial averaging; the N grid starts above d_c = 128 because for
# N < d_c the empirical cross-covariance is rank-deficient in the shared
# block (at most N of the d_c shared directions are excited), which
# produces a systematic plateau rather than smooth decay.
TRIALS = 12
Ns = [200, 400, 800, 1600, 3200, 6400]
err_multi = {}
with open(os.path.join(DATA, "e2_subspace_multi.csv"), "w") as f:
f.write("U,N,err\n")
for Um in (2, 4, 8):
a_u = math.sqrt(BETA) * np.ones(Um)
err_multi[Um] = []
for n in Ns:
errs_t = []
for t in range(TRIALS):
rng_t = np.random.default_rng(42000 + 1000 * Um
+ 10 * n + t)
z_u = sample_latents_pool(pool, n, Um, D, DC, a_u, rng_t,
idx_pool=IDX_TRAIN)
Vh, _ = learn_structure_spectral(z_u @ R.T, DC)
errs_t.append(subspace_error(Vh, V_true))
e_u = float(np.mean(errs_t))
err_multi[Um].append(e_u)
f.write(f"{Um},{n},{e_u}\n")
print(f"U={Um} N={n:5d} err={e_u:.4f} "
f"(std {np.std(errs_t):.4f})")
# power-law exponents of the subspace-error decay (quoted in the paper)
with open(os.path.join(DATA, "e2_exponents.txt"), "w") as f:
for Um in (2, 4, 8):
slope = np.polyfit(np.log(Ns), np.log(err_multi[Um]), 1)[0]
f.write(f"U={Um} exponent={slope:.3f}\n")
print(f"U={Um} power-law exponent {slope:.3f}")
# --- eigen-spectrum for several calibration sizes (fresh rng) ---------
with open(os.path.join(DATA, "e2_spectrum_multi.csv"), "w") as f:
f.write("N,idx,eig\n")
for n in (100, 400, 1600):
rng_s = np.random.default_rng(5200 + n)
z_s = sample_latents_pool(pool, n, U, D, DC, a, rng_s,
idx_pool=IDX_TRAIN)
_, ev = learn_structure_spectral(z_s @ R.T, DC)
for i, w in enumerate(ev[:400]):
f.write(f"{n},{i+1},{w}\n")
# --- calibration with the paper budget --------------------------------
_, x_cal = gen_clean(pool, N_PAIR, a, R, rng, idx_pool=IDX_TRAIN)
V_spec, _ = learn_structure_spectral(x_cal, DC)
err_spec = subspace_error(V_spec, V_true)
print(f"spectral (N={N_PAIR}): subspace_err={err_spec:.4f}")
# full basis for adapter init: complete V_spec to an orthonormal basis
Q, _ = np.linalg.qr(np.hstack([
V_spec, rng.standard_normal((D, D - DC))]))
V_full = Q
print("training adapter (same calibration pairs as the spectral step) ...")
W_ad = train_adapter(x_cal, a, V_full, rng)
err_ad = subspace_error(W_ad.T[:, :DC], V_true)
print(f"adapter: subspace_err={err_ad:.4f}")
with open(os.path.join(DATA, "e2_caliberr.txt"), "w") as f:
f.write(f"spectral_err={err_spec}\nadapter_err={err_ad}\n")
# --- performance ladder vs SNR (held-out pool sentences) ---------------
snrs = list(range(0, 21, 4))
rows = []
for s in snrs:
rho = 10 ** (s / 10)
z, x = gen_clean(pool, BATCH_EVAL, a, R, rng, idx_pool=IDX_EVAL)
tilde, h = matched_filter(x, B, rho, rng)
r = {}
lm, _ = demux_lmmse(tilde, B, h, rho)
r["LMMSE"] = metrics(lm, x)
r["DR-spec"] = metrics(demux_dr(tilde, B, h, rho, a, V_spec), x)
r["DR-adapt"] = metrics(
demux_dr(tilde, B, h, rho, a, W_ad.T[:, :DC]), x)
r["DR-oracle"] = metrics(demux_dr(tilde, B, h, rho, a, V_true), x)
rng_c = np.random.default_rng(91000 + s)
r["OMA"] = metrics(oma_observe(x, h, rho, rng_c), x)
r["NOMA"] = metrics(demux_noma_genie(x, h, rho, rng_c), x)
rows.append(r)
print(f"snr={s:2d} " + " ".join(
f"{k}:cos={v[0]:.3f},ser={v[2]:.3f}" for k, v in r.items()))
keys = ["OMA", "NOMA", "LMMSE", "DR-spec", "DR-adapt", "DR-oracle"]
with open(os.path.join(DATA, "e2_ladder.csv"), "w") as f:
f.write("snr," + ",".join(f"{k}_cos,{k}_nmse,{k}_ser" for k in keys) + "\n")
for s, r in zip(snrs, rows):
f.write(f"{s}," + ",".join(
f"{r[k][0]},{r[k][1]},{r[k][2]}" for k in keys) + "\n")
# figures are produced only by the canonical replot_all.py (uniform
# geometry); experiment scripts write CSVs exclusively.
print("E2 done. Run replot_all.py to regenerate the figures.")
if __name__ == "__main__":
main()
+161
View File
@@ -0,0 +1,161 @@
"""E3: dynamic mobile environment -- time-varying share coefficients a_u(t)
from random-waypoint trajectories, with sparse affinity-pilot tracking.
Every K-th slot each user sends n_p orthogonal pilot embeddings (overhead
n_p/(K*batch) << 1); the tracker EWMA-smooths the triangulated observations.
Methods:
DR-genie : decomposition receiver with true a_u(t) (upper ref)
DR-tracked : DR with pilot-tracked a_hat(t) (proposed)
DR-static : DR designed for the time-averaged a (no adaptation)
SR / SC / LMMSE : baselines with true B(t)
Outputs: data/e3_timeseries.csv, data/e3_speed.csv
(figures come from replot_all.py only)
"""
import os
import numpy as np
from semantic_mac import (affinity_matrix, matched_filter, demux_sr, demux_sc,
demux_lmmse, demux_dr, sample_latents_isotropic,
mobility_trajectories, AffinityTracker,
pilot_affinity_obs, metrics,
oma_observe, demux_noma_genie)
HERE = os.path.dirname(os.path.abspath(__file__))
FIG = os.path.join(HERE, "..", "fig")
DATA = os.path.join(HERE, "..", "data")
os.makedirs(FIG, exist_ok=True)
os.makedirs(DATA, exist_ok=True)
U, D, DC = 4, 64, 16
BATCH = 320
SNR_DB = 12
RHO = 10 ** (SNR_DB / 10)
T = 300
K_PILOT = 5 # pilot every K slots
N_PILOT = 64 # pilot embeddings per user per pilot slot
LAM = 0.3
METHODS = ["DR-genie", "DR-tracked", "DR-static", "SR", "SC", "LMMSE",
"OMA", "NOMA"]
MOB = dict(box=50.0, r_scene=32.0, a_max=0.95)
def run_trace(a_t, rng, collect_ts=False):
Vc = np.eye(D)[:, :DC]
a_bar = a_t.mean(axis=0)
tracker = AffinityTracker(U, lam=LAM, a_init=float(a_bar.mean()))
sers = {m: [] for m in METHODS}
coss = {m: [] for m in METHODS}
a_hat_log = []
for t in range(a_t.shape[0]):
a = a_t[t]
B = affinity_matrix(a)
z = sample_latents_isotropic(BATCH, U, D, DC, a, rng)
tilde, h = matched_filter(z, B, RHO, rng)
if t % K_PILOT == 0:
zp = sample_latents_isotropic(N_PILOT, U, D, DC, a, rng)
hp = np.clip(np.abs((rng.standard_normal(U) +
1j * rng.standard_normal(U)) / np.sqrt(2)),
0.2, None)
tracker.update(pilot_affinity_obs(zp, hp, RHO, rng))
a_hat = np.clip(tracker.a, 0.02, 0.95)
out = {}
out["DR-genie"] = demux_dr(tilde, B, h, RHO, a, Vc)
out["DR-tracked"] = demux_dr(tilde, affinity_matrix(a_hat), h, RHO,
a_hat, Vc)
out["DR-static"] = demux_dr(tilde, affinity_matrix(a_bar), h, RHO,
a_bar, Vc)
out["SR"] = demux_sr(tilde, B, h)
out["SC"] = demux_sc(tilde, h)
out["LMMSE"], _ = demux_lmmse(tilde, B, h, RHO)
out["OMA"] = oma_observe(z, h, RHO, rng)
out["NOMA"] = demux_noma_genie(z, h, RHO, rng)
a_hat_log.append(a_hat)
for m in METHODS:
c, _, s = metrics(out[m], z)
sers[m].append(s)
coss[m].append(c)
res = {m: (float(np.mean(coss[m])), float(np.mean(sers[m])))
for m in METHODS}
if collect_ts:
return res, sers, np.array(a_hat_log)
return res
def drive_through_profile(T, peaks=(80, 110, 140, 170), width=45.0,
a_lo=0.05, a_hi=0.9):
"""Scene pass-by: each user approaches the shared scene, dwells, leaves."""
t = np.arange(T)[:, None]
pk = np.asarray(peaks)[None, :]
return a_lo + (a_hi - a_lo) * np.exp(-(t - pk) ** 2 / (2 * width ** 2))
def main():
# --- time-series: scene pass-by, averaged over REPS_TS runs -------------
a_t = drive_through_profile(T)
REPS_TS = 5
sers_acc = None
means_acc = {m: [] for m in METHODS}
for rep_i in range(REPS_TS):
rng = np.random.default_rng(3 + rep_i)
res_i, sers_i, a_hat_i = run_trace(a_t, rng, collect_ts=True)
if rep_i == 0:
a_hat_log = a_hat_i
if sers_acc is None:
sers_acc = {m: np.array(sers_i[m], float) for m in METHODS}
else:
for m in METHODS:
sers_acc[m] += np.array(sers_i[m], float)
for m in METHODS:
means_acc[m].append(res_i[m])
print(f" ts rep {rep_i+1}/{REPS_TS} done")
sers = {m: (sers_acc[m] / REPS_TS).tolist() for m in METHODS}
res = {m: (float(np.mean([x[0] for x in means_acc[m]])),
float(np.mean([x[1] for x in means_acc[m]])))
for m in METHODS}
print("time-series means:", {m: f"cos={v[0]:.3f},ser={v[1]:.3f}"
for m, v in res.items()})
with open(os.path.join(DATA, "e3_timeseries.csv"), "w") as f:
f.write("t," + ",".join(f"a{u}" for u in range(U)) + ","
+ ",".join(f"ahat{u}" for u in range(U)) + ","
+ ",".join(f"{m}_ser" for m in METHODS) + "\n")
for t in range(T):
f.write(f"{t}," + ",".join(f"{a_t[t,u]:.4f}" for u in range(U))
+ "," + ",".join(f"{a_hat_log[t,u]:.4f}" for u in range(U))
+ "," + ",".join(f"{sers[m][t]:.4f}" for m in METHODS) + "\n")
# --- speed sweep (averaged over trajectory seeds) -----------------------
speeds = [0.5, 1.0, 2.0, 4.0, 8.0]
reps = 8
rows = []
for si, sp in enumerate(speeds):
acc = {m: [] for m in METHODS}
for rep in range(reps):
# disjoint seed blocks per speed point (no seed reuse across
# speeds)
rng_s = np.random.default_rng(1000 + 100 * si + rep)
a_tr = mobility_trajectories(U, T, sp, rng_s, **MOB)
r = run_trace(a_tr, rng_s)
for m in METHODS:
acc[m].append(r[m])
rows.append({m: (float(np.mean([x[0] for x in acc[m]])),
float(np.mean([x[1] for x in acc[m]])))
for m in METHODS})
print(f"speed={sp} " + " ".join(f"{m}:ser={rows[-1][m][1]:.3f}"
for m in METHODS))
with open(os.path.join(DATA, "e3_speed.csv"), "w") as f:
f.write("speed," + ",".join(f"{m}_cos,{m}_ser" for m in METHODS) + "\n")
for sp, r in zip(speeds, rows):
f.write(f"{sp}," + ",".join(f"{r[m][0]},{r[m][1]}"
for m in METHODS) + "\n")
# figures are produced only by the canonical replot_all.py (uniform
# geometry); experiment scripts write CSVs exclusively.
print("E3 done. Run replot_all.py to regenerate the figures.")
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
"""E4: robustness of the decomposition receiver to affinity estimation error.
DR runs with a_hat = a + delta; theory predicts O(delta^2) degradation.
Outputs: data/e4_mismatch.csv
(figures come from replot_all.py only)
"""
import math
import os
import numpy as np
from semantic_mac import (affinity_matrix, matched_filter, demux_dr,
sample_latents_isotropic, metrics)
HERE = os.path.dirname(os.path.abspath(__file__))
FIG = os.path.join(HERE, "..", "fig")
DATA = os.path.join(HERE, "..", "data")
os.makedirs(FIG, exist_ok=True)
os.makedirs(DATA, exist_ok=True)
U, D, DC = 4, 64, 16
BATCH = 4000
BETA = 0.5
def main():
rng = np.random.default_rng(11)
a = math.sqrt(BETA) * np.ones(U)
B = affinity_matrix(a)
Vc = np.eye(D)[:, :DC]
deltas = np.arange(-0.20, 0.201, 0.04)
rows = []
for snr_db in (5, 10, 15):
rho = 10 ** (snr_db / 10)
z = sample_latents_isotropic(BATCH, U, D, DC, a, rng)
tilde, h = matched_filter(z, B, rho, rng)
for d0 in deltas:
a_hat = np.clip(a + d0, 0.02, 0.98)
B_hat = affinity_matrix(a_hat)
cos, _, ser = metrics(
demux_dr(tilde, B_hat, h, rho, a_hat, Vc), z)
rows.append((snr_db, float(d0), cos, ser))
print(f"snr={snr_db} delta={d0:+.2f} cos={cos:.4f} ser={ser:.4f}")
with open(os.path.join(DATA, "e4_mismatch.csv"), "w") as f:
f.write("snr,delta,cos,ser\n")
for r in rows:
f.write(",".join(str(x) for x in r) + "\n")
# figures are produced only by the canonical replot_all.py (uniform
# geometry); experiment scripts write CSVs exclusively.
print("E4 done. Run replot_all.py to regenerate the figures.")
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
"""E5: comparison with a trained user-wise attention receiver.
A learned receiver representative of the end-to-end line (per-user query
attention over the matched-filter outputs, residual skip, unit
normalization) is trained at the operating SNR over uniformly random
affinities, then compared against the closed-form LMMSE and DR receivers
across the affinity sweep. The learned receiver receives no affinity side
information and must infer the coupling from data, which is the standard
setting of the learned line.
Outputs: data/e5_learned.csv, data/e5_train_log.csv
(figures come from replot_all.py only)
"""
import math
import os
import numpy as np
import torch
from semantic_mac import (affinity_matrix, matched_filter,
sample_latents_isotropic, demux_lmmse, demux_dr,
demux_sr, demux_sc, metrics)
HERE = os.path.dirname(os.path.abspath(__file__))
FIG = os.path.join(HERE, "..", "fig")
DATA = os.path.join(HERE, "..", "data")
U, D, DC = 4, 64, 16
SNR_DB = 10
RHO = 10 ** (SNR_DB / 10)
STEPS = 3000
BATCH = 64
class UserWiseAttention(torch.nn.Module):
"""Per-user query attention over the U matched-filter outputs."""
def __init__(self, U, d, dk=16, heads=4):
super().__init__()
self.U, self.d, self.dk, self.H = U, d, dk, heads
self.WK = torch.nn.Linear(d, dk * heads, bias=False)
self.WV = torch.nn.Linear(d, dk * heads, bias=False)
self.WO = torch.nn.Linear(dk * heads, d, bias=False)
self.q = torch.nn.Parameter(torch.randn(U, heads, dk) * 0.1)
self.log_eta = torch.nn.Parameter(torch.zeros(()))
def forward(self, tilde):
B, Uu, d = tilde.shape
K = self.WK(tilde).view(B, Uu, self.H, self.dk)
V = self.WV(tilde).view(B, Uu, self.H, self.dk)
sc = torch.einsum('uhk,bihk->buih', self.q, K) / math.sqrt(self.dk)
alpha = torch.softmax(torch.exp(self.log_eta) * sc, dim=2)
ctx = torch.einsum('buih,bihk->buhk', alpha, V).reshape(B, Uu, -1)
out = self.WO(ctx) + tilde
return out / (out.norm(dim=2, keepdim=True) + 1e-12)
def gen_batch(rng, batch, beta):
a = math.sqrt(beta) * np.ones(U)
B = affinity_matrix(a)
z = sample_latents_isotropic(batch, U, D, DC, a, rng)
tilde, h = matched_filter(z, B, RHO, rng)
return z, tilde, h, a, B
def main():
torch.manual_seed(0)
rng = np.random.default_rng(21)
net = UserWiseAttention(U, D)
n_par = sum(p.numel() for p in net.parameters())
print(f"learned receiver parameters: {n_par}")
opt = torch.optim.Adam(net.parameters(), lr=1e-3)
train_log = []
for it in range(STEPS):
beta = float(rng.uniform(0.05, 0.9))
z, tilde, h, a, B = gen_batch(rng, BATCH, beta)
zt = torch.tensor(z, dtype=torch.float32)
tt = torch.tensor(tilde, dtype=torch.float32)
out = net(tt)
loss = (1.0 - (out * zt).sum(-1)).mean()
opt.zero_grad()
loss.backward()
opt.step()
if (it + 1) % 50 == 0:
train_log.append((it + 1, float(loss.item())))
if (it + 1) % 500 == 0:
print(f" step {it+1}: loss={loss.item():.4f}")
# convergence evidence for the fixed training budget
with open(os.path.join(DATA, "e5_train_log.csv"), "w") as f:
f.write("step,loss\n")
for st, lo in train_log:
f.write(f"{st},{lo}\n")
# evaluation across the affinity sweep
betas = [0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
Vc = np.eye(D)[:, :DC]
rows = []
net.eval()
rng_e = np.random.default_rng(500)
for beta in betas:
z, tilde, h, a, B = gen_batch(rng_e, 4000, beta)
with torch.no_grad():
out_l = net(torch.tensor(tilde, dtype=torch.float32)).numpy()
r = {}
r["Learned"] = metrics(out_l.astype(np.float64), z)
lm, _ = demux_lmmse(tilde, B, h, RHO)
r["LMMSE"] = metrics(lm, z)
r["DR"] = metrics(demux_dr(tilde, B, h, RHO, a, Vc), z)
rows.append(r)
print(f"beta={beta:.2f} " + " ".join(
f"{k}:cos={v[0]:.3f},ser={v[2]:.3f}" for k, v in r.items()))
with open(os.path.join(DATA, "e5_learned.csv"), "w") as f:
keys = ["Learned", "LMMSE", "DR"]
f.write("beta," + ",".join(f"{k}_cos,{k}_nmse,{k}_ser" for k in keys)
+ "\n")
for b, r in zip(betas, rows):
f.write(f"{b}," + ",".join(
f"{r[k][0]},{r[k][1]},{r[k][2]}" for k in keys) + "\n")
# figures are produced only by the canonical replot_all.py (uniform
# geometry); experiment scripts write CSVs exclusively.
print("E5 done. Run replot_all.py to regenerate the figures.")
if __name__ == "__main__":
main()
+321
View File
@@ -0,0 +1,321 @@
"""
Verify Proposition-candidate 1 for the 8th (TMC) paper:
The B-aware LMMSE receiver
W*(B,H,sigma^2) = C_x Gamma^T (Gamma C_x Gamma^T + C_n)^{-1}
(i) -> Gamma^{-1} (= SR / AA-EDMA demux) as rho -> inf
(ii) -> per-user Wiener (no cross-processing) as beta -> 0
(iii) -> coherent combining (SC / MRC) as beta -> 1
(iv) dominates SR and SC at every (beta, rho); strict gap at intermediate beta.
Also simulates an oracle decomposition receiver (ODR) that knows the
shared/private subspace split — the ceiling motivating structured
embedding learning.
Self-contained: model/conventions come from the project's own
semantic_mac.py (matched filter, affinity, metrics), so this script
verifies the same library that produces the paper results.
"""
from __future__ import annotations
import math
import os
import numpy as np
from semantic_mac import affinity_matrix, matched_filter, metrics
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "..", "fig")
os.makedirs(OUT, exist_ok=True)
TAU = 0.45 # SER threshold, as in CL paper
# ----------------------------------------------------------------------
# Structured embedding generator with a FIXED shared subspace
# (needed for the ODR ceiling; statistically identical Gram to uwca_core's
# sample_embeddings: E[<e_u,e_v>] = a_u a_v)
# ----------------------------------------------------------------------
def sample_embeddings_structured(batch, U, d, d_c, a, rng):
"""Shared content c lives in the FIRST d_c coordinates (unit norm there);
private p_u are mutually orthogonal unit vectors in the remaining d-d_c.
e_u = a_u * [c; 0] + sqrt(1-a_u^2) * [0; p_u]
Returns e (batch,U,d), c (batch,d_c), p (batch,U,d-d_c)."""
a = np.broadcast_to(np.asarray(a, float), (U,))
e = np.zeros((batch, U, d))
c_all = np.zeros((batch, d_c))
p_all = np.zeros((batch, U, d - d_c))
for b in range(batch):
c = rng.standard_normal(d_c)
c /= np.linalg.norm(c)
G = rng.standard_normal((d - d_c, U))
Q, _ = np.linalg.qr(G) # orthonormal private dirs
c_all[b] = c
for u in range(U):
p_all[b, u] = Q[:, u]
e[b, u, :d_c] = a[u] * c
e[b, u, d_c:] = math.sqrt(1.0 - a[u] ** 2) * Q[:, u]
return e, c_all, p_all
# ----------------------------------------------------------------------
# Receivers (all consume the matched-filter outputs `tilde`)
# ----------------------------------------------------------------------
def lmmse_matrices(B, h, sigma2, d):
"""Return (W, Eerr) for one sample: W (U,U), Eerr (U,U) per-dim error cov."""
H = np.diag(h)
Hi = np.diag(1.0 / h)
Gamma = Hi @ B @ H
Cx = B / d
Cn = sigma2 * (Hi @ B @ Hi)
S = Gamma @ Cx @ Gamma.T + Cn
W = Cx @ Gamma.T @ np.linalg.solve(S.T, np.eye(len(h))).T # Cx Γ^T S^{-1}
Eerr = Cx - W @ Gamma @ Cx
return W, Eerr
def demux_lmmse(tilde, B, h, rho):
"""B-aware LMMSE. Returns (e_hat_raw, closed-form per-user MSE avg over batch)."""
batch, U, d = tilde.shape
sigma2 = 1.0 / rho
out = np.empty_like(tilde)
mse_cf = np.zeros(U)
for b in range(batch):
W, Eerr = lmmse_matrices(B, h[b], sigma2, d)
out[b] = W @ tilde[b]
mse_cf += d * np.diag(Eerr)
return out, mse_cf / batch
def demux_sr_raw(tilde, B, h):
"""SR (AA-EDMA) without normalization, for raw-MSE comparison."""
batch, U, d = tilde.shape
out = np.empty_like(tilde)
for b in range(batch):
H = np.diag(h[b])
Gamma = np.linalg.inv(H) @ B @ H
out[b] = np.linalg.solve(Gamma, tilde[b])
return out
def sr_mse_closed(B, h, rho, d):
"""d * sigma^2 [B^{-1}]_uu / h_u^2, averaged over batch of h."""
Binv_uu = np.diag(np.linalg.inv(B))
return (d / rho) * (Binv_uu[None, :] / h ** 2).mean(axis=0)
def demux_sc_mrc(tilde, h):
"""Pure combining (SC endpoint): every user gets the h^2-weighted sum of
all matched-filter outputs (MRC under the fully-shared hypothesis)."""
w = h ** 2 # (batch,U)
w = w / w.sum(axis=1, keepdims=True)
comb = np.einsum('bu,bud->bd', w, tilde) # (batch,d)
return np.repeat(comb[:, None, :], tilde.shape[1], axis=1)
def demux_odr(tilde, B, h, rho, a, d_c):
"""Oracle decomposition receiver: knows the fixed shared subspace
(first d_c coords), a and h.
shared: BLUE-combine the U looks at c, then Wiener shrink
private: SR (Gamma^{-1}) on the complement, then Wiener shrink
recombine e_hat_u = a_u c_hat + g_hat_u."""
batch, U, d = tilde.shape
sigma2 = 1.0 / rho
a = np.broadcast_to(np.asarray(a, float), (U,))
out = np.empty_like(tilde)
for b in range(batch):
hb = h[b]
Hi = np.diag(1.0 / hb)
Gamma = np.diag(1.0 / hb) @ B @ np.diag(hb)
Cn = sigma2 * (Hi @ B @ Hi) # per-dim MF noise cov
# ---- shared part: z_u = gamma_u * c + n_u on first d_c dims
gamma = np.array([
a[u] + sum(B[u, v] * a[v] * hb[v] / hb[u] for v in range(U) if v != u)
for u in range(U)
])
Z = tilde[b, :, :d_c] # (U, d_c)
Cn_inv = np.linalg.inv(Cn)
denom = gamma @ Cn_inv @ gamma
if denom > 1e-12: # beta=0 -> no shared part
c_hat = (gamma @ Cn_inv @ Z) / denom # BLUE, (d_c,)
# Wiener shrink: per-dim signal var 1/d_c, BLUE error var 1/denom
shrink_c = (1.0 / d_c) / (1.0 / d_c + 1.0 / denom)
c_hat = shrink_c * c_hat
else:
c_hat = np.zeros(d_c)
# ---- private part: SR on the complement
Gp = np.linalg.solve(Gamma, tilde[b, :, d_c:]) # (U, d-d_c) est of g_u
Binv = np.linalg.inv(B)
for u in range(U):
sig_p = 1.0 - a[u] ** 2 # ||g_u||^2
err_p = (d - d_c) * sigma2 * Binv[u, u] / hb[u] ** 2
shrink_p = sig_p / (sig_p + err_p)
out[b, u, :d_c] = a[u] * c_hat
out[b, u, d_c:] = shrink_p * Gp[u]
return out
# ----------------------------------------------------------------------
# Evaluation helpers
# ----------------------------------------------------------------------
def eval_all(e, tilde, h, B, rho, a, d_c, with_odr=True):
"""Return dict name -> (nmse_raw, cos, ser) plus closed forms."""
d = e.shape[2]
res = {}
def add(name, e_hat_raw):
nmse = ((e_hat_raw - e) ** 2).sum(-1).mean()
e_n = e_hat_raw / (np.linalg.norm(e_hat_raw, axis=2, keepdims=True) + 1e-12)
cos, _, ser = metrics(e_n, e, tau=TAU)
res[name] = dict(nmse=float(nmse), cos=cos, ser=ser)
add("MF (TIN)", tilde.copy())
add("SR (AA-EDMA)", demux_sr_raw(tilde, B, h))
add("SC (MRC)", demux_sc_mrc(tilde, h))
lm, mse_cf = demux_lmmse(tilde, B, h, rho)
add("LMMSE (proposed)", lm)
res["LMMSE (proposed)"]["nmse_cf"] = float(mse_cf.mean())
res["SR (AA-EDMA)"]["nmse_cf"] = float(sr_mse_closed(B, h, rho, d).mean())
if with_odr:
add("ODR (oracle)", demux_odr(tilde, B, h, rho, a, d_c))
return res
def run_sweep(betas, rho, U=4, d=64, d_c=16, batch=3000, seed=0):
rng = np.random.default_rng(seed)
rows = []
for beta in betas:
a = math.sqrt(beta) * np.ones(U)
B = affinity_matrix(a)
e, _, _ = sample_embeddings_structured(batch, U, d, d_c, a, rng)
tilde, h = matched_filter(e, B, rho, rng, fading=True)
res = eval_all(e, tilde, h, B, rho, a, d_c)
rows.append((beta, res))
lm, sr = res["LMMSE (proposed)"], res["SR (AA-EDMA)"]
print(f"beta={beta:4.2f} | LMMSE nmse {lm['nmse']:.4f} (cf {lm['nmse_cf']:.4f}) "
f"cos {lm['cos']:.3f} | SR nmse {sr['nmse']:.4f} (cf {sr['nmse_cf']:.4f}) "
f"cos {sr['cos']:.3f} | SC cos {res['SC (MRC)']['cos']:.3f} "
f"| ODR cos {res['ODR (oracle)']['cos']:.3f}")
return rows
def run_snr_sweep(beta, rhos_db, U=4, d=64, d_c=16, batch=3000, seed=1):
rng = np.random.default_rng(seed)
a = math.sqrt(beta) * np.ones(U)
B = affinity_matrix(a)
rows = []
for rdb in rhos_db:
rho = 10 ** (rdb / 10)
e, _, _ = sample_embeddings_structured(batch, U, d, d_c, a, rng)
tilde, h = matched_filter(e, B, rho, rng, fading=True)
res = eval_all(e, tilde, h, B, rho, a, d_c)
rows.append((rdb, res))
print(f"SNR={rdb:3d} dB | " + " | ".join(
f"{k.split(' ')[0]} cos {v['cos']:.3f} ser {v['ser']:.3f}"
for k, v in res.items()))
return rows
# ----------------------------------------------------------------------
# Endpoint checks (Proposition 1 (i)-(iii))
# ----------------------------------------------------------------------
def endpoint_checks(U=4, d=64, seed=2):
rng = np.random.default_rng(seed)
print("\n=== Endpoint checks ===")
# (i) high SNR: W* -> Gamma^{-1}
beta = 0.4
a = math.sqrt(beta) * np.ones(U)
B = affinity_matrix(a)
h = np.clip(np.abs((rng.standard_normal(U) + 1j * rng.standard_normal(U)) / math.sqrt(2)), 0.2, None)
for rdb in (10, 40, 80):
W, _ = lmmse_matrices(B, h, 10 ** (-rdb / 10), d)
Ginv = np.linalg.inv(np.diag(1 / h) @ B @ np.diag(h))
rel = np.linalg.norm(W - Ginv) / np.linalg.norm(Ginv)
print(f"(i) rho={rdb:2d} dB : ||W*-Gamma^-1||_F/||Gamma^-1||_F = {rel:.2e}")
# (ii) beta=0: off-diagonal of W* vanishes, diagonal = Wiener
W0, _ = lmmse_matrices(np.eye(U), h, 0.1, d)
off = np.abs(W0 - np.diag(np.diag(W0))).max()
wiener = h ** 2 / (h ** 2 + d * 0.1)
diag_err = np.abs(np.diag(W0) - wiener).max()
print(f"(ii) beta=0 : max|offdiag W*| = {off:.2e}, max|diag - Wiener| = {diag_err:.2e}")
# (iii) beta->1: W* row ~ rank-1 combining; compare to SC weights h^2-normalized
a1 = math.sqrt(0.999) * np.ones(U)
B1 = affinity_matrix(a1)
W1, _ = lmmse_matrices(B1, h, 0.1, d)
r = W1[0] * h # undo the h_v/h_u structure: effective combining weights on h_v e_v looks
r = np.abs(r) / np.abs(r).sum()
mrc = h ** 2 / (h ** 2).sum()
print(f"(iii) beta=.999 : normalized row-0 weights {np.round(r,3)} vs MRC {np.round(mrc,3)}")
# ----------------------------------------------------------------------
# Figures
# ----------------------------------------------------------------------
def plot_all(rows_beta, rows_snr, rho_db, beta_mid):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
names = ["MF (TIN)", "SR (AA-EDMA)", "SC (MRC)", "LMMSE (proposed)", "ODR (oracle)"]
styles = {"MF (TIN)": ("0.6", ":", "v"),
"SR (AA-EDMA)": ("tab:red", "--", "s"),
"SC (MRC)": ("tab:green", "-.", "^"),
"LMMSE (proposed)": ("tab:blue", "-", "o"),
"ODR (oracle)": ("k", ":", "d")}
betas = [b for b, _ in rows_beta]
# Fig 1: NMSE vs beta + closed-form overlays
fig, ax = plt.subplots(figsize=(6.4, 4.6))
for n in names:
c, ls, mk = styles[n]
ax.semilogy(betas, [r[n]["nmse"] for _, r in rows_beta], ls, color=c, marker=mk,
ms=4, label=n)
ax.semilogy(betas, [r["LMMSE (proposed)"]["nmse_cf"] for _, r in rows_beta],
'x', color="tab:blue", ms=9, mew=2, label="LMMSE closed form")
ax.semilogy(betas, [r["SR (AA-EDMA)"]["nmse_cf"] for _, r in rows_beta],
'+', color="tab:red", ms=10, mew=2, label="SR closed form")
ax.set_xlabel(r"semantic affinity $\beta$")
ax.set_ylabel("NMSE")
ax.set_title(f"NMSE vs affinity (U=4, d=64, SNR={rho_db} dB)")
ax.grid(True, which="both", alpha=0.3)
ax.legend(fontsize=8)
fig.tight_layout()
fig.savefig(os.path.join(OUT, "fig1_nmse_vs_beta.png"), dpi=160)
# Fig 2: cosine + SER vs beta
fig, axes = plt.subplots(1, 2, figsize=(11, 4.4))
for n in names:
c, ls, mk = styles[n]
axes[0].plot(betas, [r[n]["cos"] for _, r in rows_beta], ls, color=c, marker=mk, ms=4, label=n)
axes[1].semilogy(betas, [max(r[n]["ser"], 1e-4) for _, r in rows_beta], ls, color=c, marker=mk, ms=4, label=n)
axes[0].set_xlabel(r"$\beta$"); axes[0].set_ylabel("mean cosine"); axes[0].grid(alpha=0.3)
axes[1].set_xlabel(r"$\beta$"); axes[1].set_ylabel(f"SER (tau={TAU})"); axes[1].grid(True, which="both", alpha=0.3)
axes[0].legend(fontsize=8)
fig.suptitle(f"Cosine recovery / SER vs affinity (U=4, d=64, SNR={rho_db} dB)")
fig.tight_layout()
fig.savefig(os.path.join(OUT, "fig2_cos_ser_vs_beta.png"), dpi=160)
# Fig 3: SNR sweep at intermediate beta
rhos = [r for r, _ in rows_snr]
fig, ax = plt.subplots(figsize=(6.4, 4.6))
for n in names:
c, ls, mk = styles[n]
ax.semilogy(rhos, [max(r[n]["ser"], 1e-4) for _, r in rows_snr], ls, color=c, marker=mk, ms=4, label=n)
ax.set_xlabel("SNR (dB)"); ax.set_ylabel(f"SER (tau={TAU})")
ax.set_title(f"SER vs SNR at intermediate affinity beta={beta_mid}")
ax.grid(True, which="both", alpha=0.3); ax.legend(fontsize=8)
fig.tight_layout()
fig.savefig(os.path.join(OUT, "fig3_ser_vs_snr.png"), dpi=160)
print(f"\nFigures saved to {OUT}")
if __name__ == "__main__":
RHO_DB = 10
BETAS = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99]
print(f"=== beta sweep @ {RHO_DB} dB ===")
rows_beta = run_sweep(BETAS, 10 ** (RHO_DB / 10))
BETA_MID = 0.4
print(f"\n=== SNR sweep @ beta={BETA_MID} ===")
rows_snr = run_snr_sweep(BETA_MID, list(range(0, 21, 4)))
endpoint_checks()
plot_all(rows_beta, rows_snr, RHO_DB, BETA_MID)
+268
View File
@@ -0,0 +1,268 @@
"""Regenerate all paper figures from the CSVs in ../data with
publication-quality layout (no legend/curve overlap, consistent styling,
conventional-scheme baselines included).
This is the canonical figure generator; experiment scripts write the CSVs.
"""
import csv
import os
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
HERE = os.path.dirname(os.path.abspath(__file__))
FIG = os.path.join(HERE, "..", "fig")
DATA = os.path.join(HERE, "..", "data")
plt.rcParams.update({
"font.size": 8.5,
"axes.labelsize": 8.5,
"legend.fontsize": 6.5,
"xtick.labelsize": 8,
"ytick.labelsize": 8,
"lines.linewidth": 1.15,
"lines.markersize": 3.2,
})
FIGW, FIGH = 2.9, 2.25
AXRECT = [0.185, 0.18, 0.77, 0.7444] # exact 8:6 axes box, identical everywhere
def new_fig():
"""Canvas and axes rectangle identical for every figure, so every plot
box renders at exactly the same size in the paper."""
fig = plt.figure(figsize=(FIGW, FIGH))
ax = fig.add_axes(AXRECT)
return fig, ax
def load(name):
with open(os.path.join(DATA, name)) as f:
return list(csv.DictReader(f))
def savefig(fig, name):
fig.savefig(os.path.join(FIG, name))
print("saved", name)
S = {"OMA": ("0.45", ":", "v"), "NOMA": ("tab:brown", ":", "P"),
"SR": ("tab:red", "--", "s"), "SC": ("tab:green", "-.", "^"),
"LMMSE": ("tab:blue", "-", "o"), "DR": ("k", "-", "d")}
LBL = {"OMA": "OMA", "NOMA": "NOMA-SIC", "SR": "SR",
"SC": "SC", "LMMSE": "LMMSE", "DR": "Proposed DR"}
ORDER = ["OMA", "NOMA", "SR", "SC", "LMMSE", "DR"]
# ---------------------------------------------------------------- E1 beta
rows = load("e1_beta.csv")
betas = [float(r["beta"]) for r in rows]
fig, ax = new_fig()
for n in ORDER:
c, ls, mk = S[n]
ax.semilogy(betas, [float(r[f"{n}_nmse"]) for r in rows], ls, color=c,
marker=mk, label=LBL[n])
ax.semilogy(betas, [float(r["LMMSE_cf"]) for r in rows], 'x',
color="tab:blue", ms=6.5, mew=1.5, ls="none",
label="LMMSE closed form")
ax.semilogy(betas, [float(r["SR_cf"]) for r in rows], '+', color="tab:red",
ms=7.5, mew=1.5, ls="none", label="SR closed form")
ax.set_xlabel(r"affinity $\beta$")
ax.set_ylabel("NMSE")
ax.set_ylim(6e-2, 8e6)
ax.grid(True, which="both", alpha=0.3)
ax.legend(loc="upper left", ncol=2, columnspacing=0.7, handletextpad=0.4,
labelspacing=0.3)
savefig(fig, "fig_e1_beta_nmse.pdf")
fig, ax = new_fig()
for n in ORDER:
c, ls, mk = S[n]
ax.plot(betas, [float(r[f"{n}_cos"]) for r in rows], ls, color=c,
marker=mk, label=LBL[n])
ax.set_xlabel(r"affinity $\beta$")
ax.set_ylabel("mean cosine recovery")
ax.set_ylim(0.0, 1.05)
ax.grid(alpha=0.3)
ax.legend(loc="upper left", ncol=2, columnspacing=0.7, handletextpad=0.4,
labelspacing=0.3)
savefig(fig, "fig_e1_beta_cos.pdf")
# ---------------------------------------------------------------- E1 snr
rows = load("e1_snr.csv")
snrs = [float(r["snr"]) for r in rows]
fig, ax = new_fig()
for n in ORDER:
c, ls, mk = S[n]
ax.semilogy(snrs, [max(float(r[f"{n}_ser"]), 1e-4) for r in rows], ls,
color=c, marker=mk, label=LBL[n])
ax.set_xlabel("per-user SNR (dB)")
ax.set_ylabel("semantic error rate")
ax.set_ylim(8e-4, 2.5)
ax.grid(True, which="both", alpha=0.3)
ax.legend(loc="lower left", ncol=1, fontsize=6.1, handletextpad=0.4,
labelspacing=0.25, borderpad=0.3)
savefig(fig, "fig_e1_snr.pdf")
# ---------------------------------------------- E2 spectrum (multi-curve)
rows_s = load("e2_spectrum_multi.csv")
fig, ax = new_fig()
for n_cal, c, ls in ((100, "tab:orange", "-."), (400, "tab:green", "--"),
(1600, "tab:blue", "-")):
pts = [(int(r["idx"]), float(r["eig"])) for r in rows_s
if int(r["N"]) == n_cal]
ax.semilogy([p[0] for p in pts],
np.maximum([p[1] for p in pts], 1e-12), ls, color=c,
lw=1.15, label=f"$N{{=}}{n_cal}$")
ax.axvline(128, color="k", ls=":", lw=0.9)
ax.annotate(r"$d_c=128$", xy=(128, 1e-6), xytext=(150, 3e-7), fontsize=7.5,
arrowprops=dict(arrowstyle="-", lw=0.6, color="0.3"))
ax.set_xlabel("eigenvalue index")
ax.set_ylabel(r"eigenvalue of $\hat{\mathbf{\Sigma}}$")
ax.set_ylim(1e-8, 3e-1)
ax.grid(True, which="both", alpha=0.3)
ax.legend(loc="upper right", labelspacing=0.3)
savefig(fig, "fig_e2_spectrum.pdf")
# ---------------------------------------------- E2 subspace (multi-curve)
rows_n = load("e2_subspace_multi.csv")
fig, ax = new_fig()
for Um, c, mk, ls in ((2, "tab:orange", "s", "-."),
(4, "tab:blue", "o", "-"),
(8, "tab:green", "^", "--")):
pts = [(int(r["N"]), float(r["err"])) for r in rows_n
if int(r["U"]) == Um]
ax.loglog([p[0] for p in pts], [p[1] for p in pts], ls, color=c,
marker=mk, label=f"$U{{=}}{Um}$")
ax.set_xlabel("paired calibration samples $N$")
ax.set_ylabel("subspace recovery error")
ax.grid(True, which="both", alpha=0.3)
ax.legend(loc="upper right", labelspacing=0.3)
savefig(fig, "fig_e2_subspace.pdf")
# ---------------------------------------------------------------- E2 ladder
rows = load("e2_ladder.csv")
snrs = [float(r["snr"]) for r in rows]
S2 = {"OMA": ("0.45", ":", "v", "OMA"),
"NOMA": ("tab:brown", ":", "P", "NOMA-SIC"),
"LMMSE": ("tab:blue", "-", "o", "LMMSE"),
"DR-spec": ("tab:orange", "--", "s", "DR + spectral"),
"DR-adapt": ("tab:purple", "-", "^", "DR + adapter"),
"DR-oracle": ("k", ":", "d", "DR + oracle basis")}
fig, ax = new_fig()
for k, (c, ls, mk, lb) in S2.items():
ax.semilogy(snrs, [max(float(r[f"{k}_ser"]), 1e-4) for r in rows], ls,
color=c, marker=mk, label=lb)
ax.set_xlabel("per-user SNR (dB)")
ax.set_ylabel("semantic error rate")
ax.set_ylim(3e-3, 2.7)
ax.grid(True, which="both", alpha=0.3)
ax.legend(loc="lower left", labelspacing=0.3)
savefig(fig, "fig_e2_ladder.pdf")
# ---------------------------------------------------------------- E3 time
rows = load("e3_timeseries.csv")
T = len(rows)
t = np.arange(T)
U = 4
METHODS = ["OMA", "NOMA", "SR", "SC", "LMMSE", "DR-static", "DR-tracked",
"DR-genie"]
S3 = {"DR-genie": ("k", ":"), "DR-tracked": ("tab:purple", "-"),
"DR-static": ("tab:orange", "--"), "SR": ("tab:red", "--"),
"SC": ("tab:green", "-."), "LMMSE": ("tab:blue", "-"),
"OMA": ("0.45", ":"), "NOMA": ("tab:brown", ":")}
def roll(x, w=15):
"""Moving average with edge-truncated windows (no zero-padding bias:
endpoints average only the samples that exist)."""
x = np.asarray(x, float)
num = np.convolve(x, np.ones(w), mode="same")
den = np.convolve(np.ones_like(x), np.ones(w), mode="same")
return num / den
fig = plt.figure(figsize=(2.9, 4.2))
_bh = 0.77 * 2.9 * 0.75 / 4.2 # same physical box height as new_fig
axes = [fig.add_axes([0.185, 0.549, 0.77, _bh]),
fig.add_axes([0.185, 0.095, 0.77, _bh])]
axes[0].tick_params(labelbottom=False)
from matplotlib.lines import Line2D
for u in range(U):
axes[0].plot(t, [float(r[f"a{u}"]) for r in rows], lw=1.1, color=f"C{u}")
axes[0].plot(t, [float(r[f"ahat{u}"]) for r in rows], lw=0.9, ls="--",
color=f"C{u}", alpha=0.75)
axes[0].set_ylabel("share coefficient $a_u(t)$")
axes[0].set_ylim(0, 1.22)
axes[0].legend(handles=[
Line2D([], [], color="k", ls="-", lw=1.1, label="true"),
Line2D([], [], color="k", ls="--", lw=0.9, label="tracked")],
loc="upper right", ncol=2, columnspacing=0.8)
axes[0].grid(alpha=0.3)
for m in METHODS:
c, ls = S3[m]
axes[1].semilogy(t, np.clip(roll([float(r[f"{m}_ser"]) for r in rows]),
1e-3, None), ls, color=c, label=m)
axes[1].set_xlabel("time slot $t$")
axes[1].set_ylabel("semantic error rate")
axes[1].set_ylim(2e-2, 30)
axes[1].grid(True, which="both", alpha=0.3)
axes[1].legend(loc="upper center", ncol=3, columnspacing=0.7,
handletextpad=0.4, labelspacing=0.3, fontsize=6)
savefig(fig, "fig_e3_time.pdf")
# ---------------------------------------------------------------- E3 speed
rows = load("e3_speed.csv")
speeds = [float(r["speed"]) for r in rows]
marks = {"DR-genie": "d", "DR-tracked": "^", "DR-static": "s",
"SR": "v", "SC": "x", "LMMSE": "o", "OMA": "1", "NOMA": "P"}
fig, ax = new_fig()
for m in METHODS:
c, ls = S3[m]
ax.semilogy(speeds, [max(float(r[f"{m}_ser"]), 1e-4) for r in rows], ls,
color=c, marker=marks[m], label=m)
ax.set_xlabel("user speed (m/slot)")
ax.set_ylabel("mean semantic error rate")
ax.set_ylim(0.1, 40)
ax.grid(True, which="both", alpha=0.3)
ax.legend(loc="upper center", ncol=3, columnspacing=0.6, handletextpad=0.3,
handlelength=1.4, labelspacing=0.25, fontsize=5.8, borderpad=0.3)
savefig(fig, "fig_e3_speed.pdf")
# ---------------------------------------------------------------- E4
rows = load("e4_mismatch.csv")
fig, ax = new_fig()
for snr_db, c, mk in ((5, "tab:red", "s"), (10, "tab:blue", "o"),
(15, "tab:green", "^")):
pts = [(float(r["delta"]), float(r["cos"])) for r in rows
if int(r["snr"]) == snr_db]
ax.plot([p[0] for p in pts], [p[1] for p in pts], "-", color=c,
marker=mk, label=f"{snr_db} dB")
ax.set_xlabel(r"affinity estimation error $\delta$")
ax.set_ylabel("mean cosine recovery")
ax.set_ylim(0.40, 1.0)
ax.grid(alpha=0.3)
ax.legend(loc="upper center", ncol=3, columnspacing=0.9, handletextpad=0.4,
borderpad=0.3)
savefig(fig, "fig_e4_mismatch.pdf")
# ---------------------------------------------------------------- E5
rows = load("e5_learned.csv")
betas5 = [float(r["beta"]) for r in rows]
fig, ax = new_fig()
S5 = {"Learned": ("tab:red", "--", "s", "learned attention"),
"LMMSE": ("tab:blue", "-", "o", "LMMSE"),
"DR": ("k", "-", "d", "Proposed DR")}
for k, (c, ls, mk, lb) in S5.items():
ax.semilogy(betas5, [max(float(r[f"{k}_ser"]), 1e-3) for r in rows], ls,
color=c, marker=mk, label=lb)
ax.set_xlabel(r"affinity $\beta$")
ax.set_ylabel("semantic error rate")
ax.set_ylim(8e-3, 3.2)
ax.grid(True, which="both", alpha=0.3)
ax.legend(loc="lower left", labelspacing=0.3)
savefig(fig, "fig_e5_learned.pdf")
print("all figures regenerated")
+377
View File
@@ -0,0 +1,377 @@
"""
Core library for the TMC paper:
"Structured Shared-Private Embedding Multiplexing for Semantic Multiple
Access in Dynamic Mobile Networks"
Builds on the published shared-embedding multiple-access framework
(Lee, Choi, Lee, IEEE JSAC 2026, doi 10.1109/JSAC.2025.3643816).
Pipeline modeled here
---------------------
1. Content model (shared-scene decomposition), latent frame:
z_u = a_u [c; 0] + sqrt(1-a_u^2) [0; p_u] (structured latent)
c in R^{d_c}: shared scene content, p_u in R^{d-d_c}: private content.
A frozen foundation encoder outputs RAW embeddings x_u = R z_u with an
unknown orthogonal mixing R (the encoder's arbitrary basis) -- the
structure is present but hidden in the coordinates.
2. Matched-filter MAC front end (identical to the JSAC/AA-EDMA line):
tilde_x_u = x_u + sum_{v!=u} beta_uv (h_v/h_u) x_v + n_u,
Cov(n_u,n_w) = sigma^2 B_uw/(h_u h_w) I_d, sigma^2 = 1/rho.
3. Receivers: SR (cancel), SC (combine), B-aware LMMSE (optimal linear,
isotropic prior), and the decomposition receiver DR that knows the
shared-subspace basis V_c: BLUE-combining on the shared block +
SR cancellation and Wiener shrinkage on the private complement.
4. Embedding-structure optimization: closed-form spectral recovery of V_c
from the cross-covariance of paired clean embeddings (GCCA-style), and a
channel-in-the-loop learned linear adapter refining it.
5. Mobility: time-varying a_u(t) from user trajectories around a scene, and
a decision-directed EWMA affinity tracker.
"""
from __future__ import annotations
import math
import numpy as np
TAU = 0.45 # SER threshold on cosine (same operating definition as prior work)
def set_seed(seed: int):
np.random.seed(seed)
# ----------------------------------------------------------------------
# Affinity utilities
# ----------------------------------------------------------------------
def affinity_matrix(a: np.ndarray) -> np.ndarray:
"""B_uv = a_u a_v (u != v), B_uu = 1."""
a = np.asarray(a, dtype=np.float64)
B = np.outer(a, a)
np.fill_diagonal(B, 1.0)
return B
# ----------------------------------------------------------------------
# Content generators
# ----------------------------------------------------------------------
def sample_latents_isotropic(batch, U, d, d_c, a, rng):
"""Structured latents with isotropic random contents (unit norm).
Returns z of shape (batch, U, d): shared block = first d_c coords."""
a = np.broadcast_to(np.asarray(a, float), (batch, U)) \
if np.ndim(a) > 1 or np.ndim(a) == 1 else np.full((batch, U), float(a))
if a.shape != (batch, U):
a = np.broadcast_to(np.asarray(a, float), (batch, U))
z = np.zeros((batch, U, d))
c = rng.standard_normal((batch, d_c))
c /= np.linalg.norm(c, axis=1, keepdims=True)
p = rng.standard_normal((batch, U, d - d_c))
p /= np.linalg.norm(p, axis=2, keepdims=True)
z[:, :, :d_c] = a[:, :, None] * c[:, None, :]
z[:, :, d_c:] = np.sqrt(1.0 - a[:, :, None] ** 2) * p
return z
class EmbeddingPool:
"""Real PLM embedding pool (e.g., BERT AG-News, 8000 x 768).
Centered + unit-normalized; provides PCA coordinates so that structured
latents can be built from real semantic content."""
def __init__(self, X: np.ndarray):
X = np.asarray(X, dtype=np.float64)
self.mu = X.mean(axis=0, keepdims=True)
Xc = X - self.mu
Xc /= np.linalg.norm(Xc, axis=1, keepdims=True)
self.X = Xc
# PCA basis of the (centered, normalized) pool
_, S, Vt = np.linalg.svd(Xc, full_matrices=False)
self.pca = Vt # (d, d) rows = principal directions
self.spectrum = S ** 2 / len(Xc)
self.N, self.d = Xc.shape
def pca_coords(self, idx, k):
"""Top-k PCA coordinates of pool items idx, renormalized to unit."""
Y = self.X[idx] @ self.pca[:k].T
return Y / (np.linalg.norm(Y, axis=1, keepdims=True) + 1e-12)
def sample_latents_pool(pool: EmbeddingPool, batch, U, d, d_c, a, rng,
idx_pool=None):
"""Structured latents whose shared/private contents are REAL embeddings:
shared c = top-d_c PCA coords of one pool sentence, private p_u =
top-(d-d_c) PCA coords of distinct other sentences.
idx_pool: optional index array restricting which pool sentences may be
drawn (train/holdout partition); None draws from the whole pool."""
a = np.broadcast_to(np.asarray(a, float), (U,))
choices = np.arange(pool.N) if idx_pool is None else np.asarray(idx_pool)
idx = np.array([rng.choice(choices, size=U + 1, replace=False)
for _ in range(batch)])
c = pool.pca_coords(idx[:, 0], d_c) # (batch, d_c)
z = np.zeros((batch, U, d))
for u in range(U):
p = pool.pca_coords(idx[:, u + 1], d - d_c)
z[:, u, :d_c] = a[u] * c
z[:, u, d_c:] = math.sqrt(1.0 - a[u] ** 2) * p
return z
def random_orthogonal(d, rng):
G = rng.standard_normal((d, d))
Q, Rr = np.linalg.qr(G)
Q *= np.sign(np.diag(Rr))
return Q
# ----------------------------------------------------------------------
# Matched-filter MAC front end (JSAC / AA-EDMA convention)
# ----------------------------------------------------------------------
def matched_filter(e, B, rho, rng, fading=True):
"""tilde_u = sum_v (h_v/h_u) B_uv e_v + n_u,
Cov(n_u,n_w) = (sigma^2 B_uw / (h_u h_w)) I_d. Returns (tilde, h)."""
batch, U, d = e.shape
sigma2 = 1.0 / rho
B = np.asarray(B, dtype=np.float64)
if B.ndim == 2:
B = np.broadcast_to(B, (batch, U, U))
if fading:
hc = (rng.standard_normal((batch, U)) +
1j * rng.standard_normal((batch, U))) / math.sqrt(2)
h = np.clip(np.abs(hc), 0.2, None)
else:
h = np.ones((batch, U))
tilde = np.einsum('buv,bvd,bv,bu->bud', B, e, h, 1.0 / h)
xi = rng.standard_normal((batch, U, d)) * math.sqrt(sigma2)
for b in range(batch):
A = np.linalg.cholesky(B[b])
tilde[b] += (A @ xi[b]) / h[b][:, None]
return tilde, h
# ----------------------------------------------------------------------
# Receivers
# ----------------------------------------------------------------------
def demux_sr(tilde, B, h):
"""Similarity-rejecting closed-form demux: (Gamma^{-1} (x) I) tilde."""
batch, U, d = tilde.shape
out = np.empty_like(tilde)
for b in range(batch):
Gamma = np.diag(1.0 / h[b]) @ B @ np.diag(h[b])
out[b] = np.linalg.solve(Gamma, tilde[b])
return out
def demux_sc(tilde, h):
"""Similarity-combining endpoint: h^2-weighted MRC of all MF outputs."""
w = h ** 2
w = w / w.sum(axis=1, keepdims=True)
comb = np.einsum('bu,bud->bd', w, tilde)
return np.repeat(comb[:, None, :], tilde.shape[1], axis=1)
def lmmse_matrices(B, h, sigma2, d):
H = np.diag(h)
Hi = np.diag(1.0 / h)
Gamma = Hi @ B @ H
Cx = B / d
Cn = sigma2 * (Hi @ B @ Hi)
S = Gamma @ Cx @ Gamma.T + Cn
W = np.linalg.solve(S.T, (Cx @ Gamma.T).T).T
Eerr = Cx - W @ Gamma @ Cx
return W, Eerr
def demux_lmmse(tilde, B, h, rho):
"""B-aware LMMSE (optimal linear receiver under isotropic prior).
Returns (estimates, closed-form per-user total MSE averaged over batch)."""
batch, U, d = tilde.shape
sigma2 = 1.0 / rho
out = np.empty_like(tilde)
mse_cf = np.zeros(U)
for b in range(batch):
W, Eerr = lmmse_matrices(B, h[b], sigma2, d)
out[b] = W @ tilde[b]
mse_cf += d * np.diag(Eerr)
return out, mse_cf / batch
def demux_dr(tilde, B, h, rho, a, Vc):
"""Decomposition receiver (proposed).
Vc: (d, d_c) orthonormal basis of the shared subspace (from the
embedding-structure optimizer; oracle = true mixing columns).
Shared block: BLUE-combining of the U looks at the common content,
followed by Wiener shrinkage. Private complement: SR cancellation +
per-user Wiener shrinkage. Recombine."""
batch, U, d = tilde.shape
d_c = Vc.shape[1]
sigma2 = 1.0 / rho
a = np.broadcast_to(np.asarray(a, float), (U,))
Binv = np.linalg.inv(B)
out = np.empty_like(tilde)
Zs = tilde @ Vc # (batch, U, d_c) shared-block obs
Zp = tilde - (Zs @ Vc.T) # complement part (in ambient frame)
for b in range(batch):
hb = h[b]
Hi = np.diag(1.0 / hb)
Gamma = Hi @ B @ np.diag(hb)
Cn = sigma2 * (Hi @ B @ Hi)
gamma = np.array([
a[u] + sum(B[u, v] * a[v] * hb[v] / hb[u]
for v in range(U) if v != u) for u in range(U)])
Cn_inv = np.linalg.inv(Cn)
denom = float(gamma @ Cn_inv @ gamma)
if denom > 1e-12:
c_hat = (gamma @ Cn_inv @ Zs[b]) / denom
c_hat *= (1.0 / d_c) / (1.0 / d_c + 1.0 / denom)
else:
c_hat = np.zeros(d_c)
Gp = np.linalg.solve(Gamma, Zp[b]) # SR on the complement
for u in range(U):
sig_p = 1.0 - a[u] ** 2
err_p = (d - d_c) * sigma2 * Binv[u, u] / hb[u] ** 2
shrink = sig_p / (sig_p + err_p) if sig_p > 0 else 0.0
out[b, u] = a[u] * (Vc @ c_hat) + shrink * Gp[u]
return out
# ----------------------------------------------------------------------
# Conventional baselines (orthogonal and power-domain multiple access)
# ----------------------------------------------------------------------
def oma_observe(e, h, rho, rng):
"""Conventional orthogonal MA (OFDMA-style): user u is confined to a
disjoint d/U-dimensional block and decoded only from that block, so it
never sees cross-user interference but its recovery is capped by the
1/U-energy subspace (cosine ceiling ~ sqrt(1/U))."""
batch, U, d = e.shape
blk = d // U
sigma = math.sqrt(1.0 / rho)
out = np.zeros_like(e)
for u in range(U):
sl = slice(u * blk, (u + 1) * blk)
out[:, u, sl] = e[:, u, sl] + \
rng.standard_normal((batch, blk)) * sigma / h[:, u][:, None]
return out
def demux_noma_genie(e, h, rho, rng):
"""Genie-aided NOMA-SIC upper bound: every user decoded from an
interference-free observation e_u + n/h_u at per-user SNR rho
(perfect cancellation, no error propagation)."""
batch, U, d = e.shape
sigma = math.sqrt(1.0 / rho)
return e + rng.standard_normal(e.shape) * sigma / h[:, :, None]
# ----------------------------------------------------------------------
# Embedding-structure optimization
# ----------------------------------------------------------------------
def learn_structure_spectral(x_clean, d_c):
"""Closed-form shared-subspace recovery from N paired CLEAN embeddings.
x_clean: (N, U, d) raw (mixed) embeddings of co-located users.
The averaged symmetrized cross-covariance has column space equal to the
shared subspace (private parts are independent and average out).
Returns Vc_hat (d, d_c), eigenvalues (d,)."""
N, U, d = x_clean.shape
M = np.zeros((d, d))
cnt = 0
for u in range(U):
for v in range(u + 1, U):
C = x_clean[:, u, :].T @ x_clean[:, v, :] / N
M += C + C.T
cnt += 2
M /= cnt
w, V = np.linalg.eigh(M)
order = np.argsort(w)[::-1]
return V[:, order[:d_c]], w[order]
def subspace_error(Vhat, Vtrue):
"""Normalized projection-Frobenius distance in [0,1]."""
P1 = Vhat @ Vhat.T
P2 = Vtrue @ Vtrue.T
k = Vtrue.shape[1]
return float(np.linalg.norm(P1 - P2) / math.sqrt(2 * k))
def estimate_a_from_clean(x_clean, Vc):
"""a_u estimate from clean paired data: sqrt(mean shared-block energy)."""
E = np.linalg.norm(x_clean @ Vc, axis=2) ** 2 # (N, U)
return np.sqrt(np.clip(E.mean(axis=0), 0.0, 1.0))
# ----------------------------------------------------------------------
# Mobility model and online affinity tracking
# ----------------------------------------------------------------------
def mobility_trajectories(U, T, speed, rng, box=60.0, r_scene=28.0,
a_max=0.95, dt=1.0):
"""Random-waypoint trajectories around a scene at the origin.
Returns a_t of shape (T, U): a_u(t) = a_max * exp(-d_u(t)^2 / (2 r^2))."""
pos = rng.uniform(-box, box, size=(U, 2))
wp = rng.uniform(-box, box, size=(U, 2))
a_t = np.zeros((T, U))
for t in range(T):
for u in range(U):
vec = wp[u] - pos[u]
dist = np.linalg.norm(vec)
if dist < speed * dt:
wp[u] = rng.uniform(-box, box, size=2)
else:
pos[u] += (speed * dt) * vec / dist
d2 = (pos ** 2).sum(axis=1)
a_t[t] = a_max * np.exp(-d2 / (2 * r_scene ** 2))
return a_t
def pilot_affinity_obs(e_clean, h, rho, rng, a_cap=0.95):
"""Affinity observation from one orthogonal pilot slot.
Each user transmits n_p clean embeddings on an interference-free pilot
resource; the receiver observes e_u + n/h_u. Off-diagonal Gram entries
of the pilot observations are unbiased for beta_uv = a_u a_v (independent
noises), so no bias correction is needed. The share coefficients are
then the rank-one alternating-least-squares fit of the off-diagonal
Gram, which uses all pairs jointly."""
n_p, U, d = e_clean.shape
sigma = math.sqrt(1.0 / rho)
y = e_clean + rng.standard_normal(e_clean.shape) * sigma / h[None, :, None]
G = np.einsum('bud,bvd->buv', y, y).mean(axis=0)
# rank-1 least-squares fit of the off-diagonal Gram: G_uv ~ a_u a_v.
# Alternating least squares; uses all pairs jointly (no small-denominator
# amplification, robust in low-affinity regimes).
mask = ~np.eye(U, dtype=bool)
a = np.sqrt(np.clip(np.abs(G[mask]).reshape(U, U - 1).mean(axis=1),
1e-4, a_cap ** 2))
for _ in range(20):
for u in range(U):
others = [v for v in range(U) if v != u]
num = sum(G[u, v] * a[v] for v in others)
den = sum(a[v] ** 2 for v in others) + 1e-9
a[u] = np.clip(num / den, 0.0, a_cap)
return a
class AffinityTracker:
"""EWMA tracker of the per-user share coefficients, driven by sparse
orthogonal affinity-pilot observations (every K-th slot)."""
def __init__(self, U, lam=0.5, a_init=0.3):
self.a = np.full(U, float(a_init))
self.lam = lam
def update(self, obs):
self.a = (1 - self.lam) * self.a + self.lam * obs
return self.a.copy()
# ----------------------------------------------------------------------
# Metrics
# ----------------------------------------------------------------------
def metrics(e_hat, e_true, tau=TAU):
"""(mean cosine, NMSE of raw estimate, SER). e_true unit-norm."""
nmse = ((e_hat - e_true) ** 2).sum(-1).mean()
e_n = e_hat / (np.linalg.norm(e_hat, axis=2, keepdims=True) + 1e-12)
cos = (e_n * e_true).sum(-1)
return float(cos.mean()), float(nmse), float((cos < tau).mean())