Simulation code and data for the TMC submission
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user