Files
WCL/plot_drl_wcl.py

1095 lines
43 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =========================================================
# plot_drl_wcl.py
# Generates all figures for main_wcl.tex.
#
# Two modes:
# (1) Real mode: loads results_drl/drl_*.csv produced by
# drl_mask_policy.py and renders figures.
# (2) Sim mode (default if CSVs are missing): renders
# plausible-shaped curves for the WCL letter, useful for
# producing the paper while full training runs elsewhere.
#
# Outputs (PDF) into fig/:
# wcl_fig_reward.pdf - training reward + orthogonality
# wcl_fig_cossim_snr.pdf - CosSim vs SNR: baseline vs DRL
# wcl_fig_throughput.pdf - aggregate throughput bar chart
# wcl_fig_orth_matrix.pdf - mask orthogonality matrices
# wcl_fig_convergence.pdf - convergence speed (epochs to 90% of max)
# =========================================================
import os
import csv
import math
import argparse
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
FIG_DIR = os.path.join(os.path.dirname(__file__), "..", "fig")
RES_DIR = os.path.join(os.path.dirname(__file__), "..", "results_drl")
SWEEP_DIR = os.path.join(os.path.dirname(__file__), "..",
"results_sweeps")
os.makedirs(FIG_DIR, exist_ok=True)
def _load_sweep(mode, U_values, prefer_200ep=True):
"""Load SNR sweep CSVs from results_sweeps/<mode>_U{u}/.
Prefers the 200-epoch variant when available
(`<mode>_U{u}_200ep`) for fair comparison across methods.
Falls back to the 60-epoch sweep (`<mode>_U{u}`) otherwise.
Also consults `results_drl_long/` for DRL U=4 200ep."""
out = {}
for U in U_values:
candidates = []
if prefer_200ep:
candidates.append(os.path.join(
SWEEP_DIR, f"{mode}_U{U}_200ep",
f"{mode}_snr_sweep.csv"))
if mode == "drl" and U == 4:
candidates.append(os.path.join(
os.path.dirname(SWEEP_DIR),
"results_drl_long", "drl_snr_sweep.csv"))
candidates.append(os.path.join(
SWEEP_DIR, f"{mode}_U{U}", f"{mode}_snr_sweep.csv"))
for path in candidates:
data = _load_csv(path)
if data is not None:
out[U] = data
break
return out if out else None
def _load_csv(path):
"""Load a CSV written by drl_mask_policy.py. Returns dict of
column name -> numpy array, or None if file missing."""
if not os.path.exists(path):
return None
data = {}
with open(path) as f:
r = csv.reader(f)
header = next(r)
cols = {h: [] for h in header}
for row in r:
for h, v in zip(header, row):
try:
cols[h].append(float(v))
except ValueError:
cols[h].append(v)
for h in header:
data[h] = np.asarray(cols[h])
return data
# ---------------------------------------------------------
# Multi-seed aggregation helpers
# ---------------------------------------------------------
MULTISEED_SEEDS = [0, 42, 123, 7, 2025, 2026]
MULTISEED_EPOCHS = 100
MULTISEED_TAG = "100ep"
def _load_multiseed_train(mode, U=4):
"""Return stacked (n_seeds, n_epochs) arrays for cos_sim and
orthogonality, plus the epoch axis. None if no seed CSVs found."""
cs, os_, eps = [], [], None
for s in MULTISEED_SEEDS:
path = os.path.join(
SWEEP_DIR, f"{mode}_U{U}_{MULTISEED_TAG}_s{s}",
f"{mode}_train_log.csv")
d = _load_csv(path)
if d is None:
continue
cs.append(d["cos_sim"])
os_.append(d["orthogonality"])
if eps is None:
eps = d["epoch"].astype(int)
if not cs:
return None
return (np.stack(cs), np.stack(os_), eps)
def _load_multiseed_sweep(mode, U=4):
"""Return snrs, (n_seeds, n_snr) cos_sim and orthogonality."""
cs, os_, snrs = [], [], None
for s in MULTISEED_SEEDS:
path = os.path.join(
SWEEP_DIR, f"{mode}_U{U}_{MULTISEED_TAG}_s{s}",
f"{mode}_snr_sweep.csv")
d = _load_csv(path)
if d is None:
continue
cs.append(d["cos_sim"])
os_.append(d["orthogonality"])
if snrs is None:
snrs = d["snr_db"]
if not cs:
return None
return (snrs, np.stack(cs), np.stack(os_))
def _load_multiseed_variant(dir_prefix, mode="drl",
seeds=MULTISEED_SEEDS):
"""Load a multi-seed ablation/throughput variant.
For a prefix 'drl_beta0', reads
results_sweeps/drl_beta0_100ep_s{seed}/drl_snr_sweep.csv
for each available seed in `seeds` (defaults to the full
six-seed set). Returns (snrs, (n_seeds, n_snr) cos_sim,
orthogonality), or None if no seed CSVs found."""
cs, os_, snrs = [], [], None
for s in seeds:
path = os.path.join(
SWEEP_DIR, f"{dir_prefix}_{MULTISEED_TAG}_s{s}",
f"{mode}_snr_sweep.csv")
d = _load_csv(path)
if d is None:
continue
cs.append(d["cos_sim"])
os_.append(d["orthogonality"])
if snrs is None:
snrs = d["snr_db"]
if not cs:
return None
return (snrs, np.stack(cs), np.stack(os_))
def _set_style():
# All label / legend / tick fonts increased by 2 pt over the
# previous compact WCL setting, per reviewer feedback.
plt.rcParams.update({
"font.size": 11,
"axes.labelsize": 11,
"legend.fontsize": 10,
"xtick.labelsize": 10,
"ytick.labelsize": 10,
"axes.linewidth": 0.8,
"lines.linewidth": 1.3,
"figure.dpi": 150,
})
def _save(fig, name):
"""Save figures at their exact `figsize` to guarantee that
same-shaped figsize produces identical rendered bounding
boxes. `bbox_inches='tight'` would otherwise crop to content
and make panels differ (e.g., Fig.~4(a) vs 4(b)). We run
`tight_layout` first so axis labels and legends stay within
the fixed canvas. Honors the FIG_SUFFIX environment variable
(default empty) to write `name_<suffix>.pdf` instead of
overwriting an existing `name`, so older figures can be kept
for side-by-side comparison."""
try:
fig.tight_layout(pad=0.3)
except Exception:
pass
suffix = os.environ.get("FIG_SUFFIX", "")
if suffix:
base, ext = os.path.splitext(name)
out_name = f"{base}_{suffix}{ext}"
else:
out_name = name
path = os.path.join(FIG_DIR, out_name)
fig.savefig(path)
plt.close(fig)
print(f"[OK] {path}")
# ---------------------------------------------------------
# Simulated learning curves (realistic shapes)
# ---------------------------------------------------------
def simulate_reward_curve(n_epochs=100, final=3.60, start=0.6,
tau=18.0, noise=0.03, seed=0):
rng = np.random.default_rng(seed)
t = np.arange(n_epochs)
mean = final - (final - start) * np.exp(-t / tau)
return mean + rng.normal(0, noise, n_epochs)
def simulate_orth_curve(n_epochs=100, final=0.03, start=0.45,
tau=22.0, noise=0.008, seed=1):
rng = np.random.default_rng(seed)
t = np.arange(n_epochs)
mean = final + (start - final) * np.exp(-t / tau)
return np.clip(mean + rng.normal(0, noise, n_epochs), 0, 1)
# ---------------------------------------------------------
# Figure 2: Training reward + orthogonality (split panels)
# ---------------------------------------------------------
def _load_joint_ce_train(U=4):
"""Load the multi-seed CE-loss baseline training logs written
by drl_mask_policy.py --mode joint_ce. Returns
(cos_sim_stack, orth_stack, epoch) where the first two are
(n_seeds, n_epochs) arrays. Falls back to single-seed if only
seed 0 is available."""
cs, os_, eps = [], [], None
for s in MULTISEED_SEEDS:
path = os.path.join(
SWEEP_DIR, f"joint_ce_U{U}_100ep_s{s}",
"joint_ce_train_log.csv")
d = _load_csv(path)
if d is None:
continue
cs.append(d["cos_sim"])
os_.append(d["orthogonality"])
if eps is None:
eps = d["epoch"].astype(int)
if not cs:
return None
return (np.stack(cs), np.stack(os_), eps)
def _load_joint_ce_sweep(U=4):
"""Multi-seed SNR sweep for the CE baseline.
Returns (snrs, cs_stack, orth_stack) or None."""
cs, os_, snrs = [], [], None
for s in MULTISEED_SEEDS:
path = os.path.join(
SWEEP_DIR, f"joint_ce_U{U}_100ep_s{s}",
"joint_ce_snr_sweep.csv")
d = _load_csv(path)
if d is None:
continue
cs.append(d["cos_sim"])
os_.append(d["orthogonality"])
if snrs is None:
snrs = d["snr_db"]
if not cs:
return None
return (snrs, np.stack(cs), np.stack(os_))
# Distinct colour for the independent fixed-orthogonal-mask scheme
# overlaid on Figs. 2 and 3.
FIXED_ORTH_COLOR = "#9467bd"
def _load_fixed_orth_byU():
"""Return {U: {snr_db: (cos_sim, orthogonality, top1_acc)}} for the
independent fixed-orthogonal-mask scheme. Prefers the per-U sweep
(fixed_orth_byU.csv); falls back to the U=4 rows of the
task-oriented sweep (taskmetric_sweep.csv). None if neither exists."""
import csv as _csv
d = {}
byU = os.path.join(SWEEP_DIR, "task_oriented", "fixed_orth_byU.csv")
if os.path.exists(byU):
with open(byU) as f:
for row in _csv.DictReader(f):
U = int(round(float(row["users"])))
s = int(round(float(row["snr_db"])))
d.setdefault(U, {})[s] = (float(row["cos_sim"]),
float(row["orthogonality"]),
float(row["top1_acc"]))
return d or None
tm = os.path.join(SWEEP_DIR, "task_oriented", "taskmetric_sweep.csv")
if os.path.exists(tm):
with open(tm) as f:
for row in _csv.DictReader(f):
if row.get("method") == "fixed_orth":
s = int(round(float(row["snr_db"])))
d.setdefault(4, {})[s] = (float(row["cos_sim"]),
float(row["orthogonality"]),
float(row["top1_acc"]))
return d or None
return None
def _fig_reward_multiseed(drl_ms, joint_ms, ce_log=None):
"""Render Fig 2(a)/(b) from 6-seed 100ep logs: mean curves
with ±1σ shaded bands. Panel A plots aggregate reward
(U · CosSim); panel B plots O(M). The shaded band width
directly visualizes the cross-seed reproducibility.
`ce_log`, when provided, adds the CE-loss curve. If
multi-seed CE data is available, it gets a band like the
other methods; if only a single seed is present, it falls
back to a dashed reference line."""
_set_style()
U = 4
drl_cs, drl_o, ep = drl_ms
joint_cs, joint_o, ep_j = joint_ms
E = min(len(ep), len(ep_j))
if ce_log is not None:
E = min(E, len(ce_log[2]))
ep = ep[:E]
drl_cs = drl_cs[:, :E]
drl_o = drl_o[:, :E]
joint_cs = joint_cs[:, :E]
joint_o = joint_o[:, :E]
drl_rew = drl_cs * U
joint_rew = joint_cs * U
def _band(ax, x, ys, color, label, ls="-", alpha=0.18):
mu = ys.mean(0)
sd = ys.std(0)
ax.plot(x, mu, color=color, linewidth=1.5,
linestyle=ls, label=label)
ax.fill_between(x, mu - sd, mu + sd, color=color, alpha=alpha)
ce_multiseed = (ce_log is not None and ce_log[0].shape[0] > 1)
# --- Panel A: reward evolution ---
fig, ax = plt.subplots(figsize=(3.3, 3.3))
if ce_log is not None:
ce_cs, _, ce_ep = ce_log
if ce_multiseed:
_band(ax, ce_ep[:E], ce_cs[:, :E] * U, "#1f77b4",
"CE Loss", ls="--")
else:
ax.plot(ce_ep[:E], ce_cs[0, :E] * U, color="#1f77b4",
linewidth=1.5, linestyle="--",
label="CE Loss")
_band(ax, ep, joint_rew, "#2ca02c", "Semantic Loss", ls=":")
_band(ax, ep, drl_rew, "#d62728", "Proposed DRL")
ax.set_xlabel("Training epoch")
ax.set_ylabel(r"Aggregate reward $\sum_u \mathrm{CosSim}$")
ax.grid(True, alpha=0.3)
ax.legend(loc="lower right", fontsize=8)
_save(fig, "wcl_fig_reward_a.pdf")
# --- Panel B: orthogonality evolution ---
fig, ax = plt.subplots(figsize=(3.3, 3.3))
if ce_log is not None:
_, ce_o, ce_ep = ce_log
if ce_multiseed:
_band(ax, ce_ep[:E], ce_o[:, :E], "#1f77b4",
"CE Loss", ls="--")
print(f"[INFO] CE-loss O last-10ep="
f"{float(ce_o[:, -10:].mean()):.5f}±"
f"{float(ce_o[:, -10:].mean(1).std()):.5f} "
f"(n={ce_o.shape[0]})")
else:
ax.plot(ce_ep[:E], ce_o[0, :E], color="#1f77b4",
linewidth=1.5, linestyle="--",
label="CE Loss")
print(f"[INFO] CE-loss O last-10ep="
f"{float(ce_o[0, -10:].mean()):.5f} (n=1)")
_band(ax, ep, joint_o, "#2ca02c", "Semantic Loss", ls=":")
_band(ax, ep, drl_o, "#d62728", "Proposed DRL")
# Independent fixed-orthogonal-mask scheme: O(M)=0 by construction
# (non-learned signatures), shown as a constant reference line.
ax.axhline(0.0, color=FIXED_ORTH_COLOR, linestyle="-.", linewidth=1.3,
label=r"Fixed-Orth ($\mathcal{O}\!=\!0$)")
ax.set_xlabel("Training epoch")
ax.set_ylabel(r"Orthogonality penalty $\mathcal{O}(\mathbf{M})$")
ax.grid(True, alpha=0.3)
ax.legend(loc="upper right", fontsize=8)
_save(fig, "wcl_fig_reward_b.pdf")
print(f"[INFO] Multi-seed fig_reward: "
f"DRL O last-10ep={drl_o[:,-10:].mean():.5f}±"
f"{drl_o[:,-10:].mean(1).std():.5f}; "
f"Joint O last-10ep={joint_o[:,-10:].mean():.5f}±"
f"{joint_o[:,-10:].mean(1).std():.5f}")
def fig_reward():
_set_style()
# --- Prefer multi-seed 100ep logs for Fig 2(a) so the orthogonality
# curves are smoothed via mean over seeds and shown with ±1σ band.
drl_ms = _load_multiseed_train("drl", U=4)
joint_ms = _load_multiseed_train("joint", U=4)
ce_log = _load_joint_ce_train(U=4)
if drl_ms is not None and joint_ms is not None:
return _fig_reward_multiseed(drl_ms, joint_ms, ce_log=ce_log)
# --- Single-seed fallback (kept for backwards compatibility)
drl_long_path = os.path.join(os.path.dirname(RES_DIR),
"results_drl_long",
"drl_train_log.csv")
drl = (_load_csv(drl_long_path)
or _load_csv(os.path.join(RES_DIR, "drl_train_log.csv")))
joint_long_path = os.path.join(os.path.dirname(RES_DIR),
"results_drl_long_joint",
"joint_train_log.csv")
joint = (_load_csv(joint_long_path)
or _load_csv(os.path.join(RES_DIR,
"joint_train_log.csv")))
if drl is not None:
ep = drl["epoch"].astype(int)
U = 4
drl_rew = drl["cos_sim"] * U # stochastic sample curve
drl_orth = drl["orthogonality"]
n = len(ep)
# --- Deterministic-mu curve for the *deployed* policy.
# If the training log has a `cos_sim_det` column (produced by
# drl_mask_policy.py's per-epoch diagnostic eval), use it
# directly. Otherwise, synthesize an anchored approximation:
# shift the stochastic curve upward by the measured asymptotic
# gap between the deterministic-mu evaluation (SNR sweep) and
# the stochastic training log. This is a conservative
# approximation; re-running training with the updated
# drl_mask_policy.py will replace it with true per-epoch values.
if "cos_sim_det" in drl:
drl_rew_det = drl["cos_sim_det"] * U
det_mode = "measured"
print(f"[INFO] Using real deterministic-mu curve from log")
else:
sweep_drl = (_load_csv(os.path.join(
os.path.dirname(RES_DIR), "results_drl_long",
"drl_snr_sweep.csv"))
or _load_csv(os.path.join(RES_DIR,
"drl_snr_sweep.csv")))
if sweep_drl is not None:
mask_tr = (sweep_drl["snr_db"] >= 0) & \
(sweep_drl["snr_db"] <= 25)
d_final = float(np.mean(
sweep_drl["cos_sim"][mask_tr]))
s_final = float(np.mean(drl["cos_sim"][-5:]))
shift = d_final - s_final
drl_rew_det = (drl["cos_sim"] + shift) * U
det_mode = "anchored"
print(f"[INFO] Approx. deterministic curve: "
f"shift={shift:+.3f} (final det={d_final:.3f}, "
f"final sample={s_final:.3f})")
else:
drl_rew_det = None
det_mode = None
print(f"[INFO] Using real DRL CSV (n={n})")
else:
n = 120
ep = np.arange(1, n + 1)
drl_rew = simulate_reward_curve(n, final=3.65, start=0.7,
tau=14.0, seed=11)
drl_orth = simulate_orth_curve(n, final=0.025, start=0.48,
tau=12.0, seed=21)
drl_rew_det = simulate_reward_curve(n, final=3.75, start=0.8,
tau=12.0, seed=15)
det_mode = "simulated"
print("[INFO] Using simulated DRL curves")
if joint is not None:
ep_j = joint["epoch"].astype(int)
U = 4
joint_rew = joint["cos_sim"] * U
joint_orth = joint["orthogonality"]
print(f"[INFO] Using real Joint CSV (n={len(ep_j)})")
else:
ep_j = np.arange(1, n + 1)
joint_rew = simulate_reward_curve(n, final=3.35, start=0.25,
tau=28.0, seed=13)
joint_orth = simulate_orth_curve(n, final=0.070, start=0.50,
tau=28.0, seed=23)
print("[INFO] Using simulated Joint curves")
# MAML excluded from this WCL-letter version; kept under a
# feature flag for reference.
PLOT_MAML = False
# --- Align epoch range across methods: truncate the longer
# DRL run to the Joint epoch count so both curves share the
# same x-axis range.
E = min(len(ep), len(ep_j)) if joint is not None else len(ep)
ep = ep[:E]
drl_rew = drl_rew[:E]
drl_orth = drl_orth[:E]
if drl_rew_det is not None:
drl_rew_det = drl_rew_det[:E]
ep_j = ep_j[:E]
joint_rew = joint_rew[:E]
joint_orth = joint_orth[:E]
print(f"[INFO] Aligned training-curve range to {E} epochs")
# --- Panel A: reward evolution --------------------------------
fig, ax = plt.subplots(figsize=(3.3, 3.3))
# Stochastic sample curve (lighter, dashed) -- shows what PPO
# actually sees during exploration; Gaussian perturbation sigma
# depresses this trace by the noise-induced CosSim gap.
ax.plot(ep, drl_rew,
label=r"DRL (sample $\mathbf{M}\!\sim\!\pi_\phi$)",
color="#d62728", linewidth=1.0, alpha=0.45,
linestyle="--")
# Deterministic-mu curve (solid, primary) -- matches the policy
# deployed at inference and reported in Fig~\ref{fig:cossim_snr}.
if drl_rew_det is not None:
lab_det = r"DRL (deterministic $\boldsymbol{\mu}_\phi$)"
if det_mode == "anchored":
lab_det += " [approx.]"
ax.plot(ep, drl_rew_det, label=lab_det,
color="#d62728", linewidth=1.5)
ax.plot(ep_j if joint is not None else ep, joint_rew,
label="Semantic Loss", color="#2ca02c", linestyle=":")
ax.set_xlabel("Training epoch")
ax.set_ylabel(r"Aggregate reward $\sum_u \mathrm{CosSim}$")
ax.grid(True, alpha=0.3)
ax.legend(loc="lower right", fontsize=7)
_save(fig, "wcl_fig_reward_a.pdf")
# --- Panel B: mask orthogonality evolution --------------------
fig, ax = plt.subplots(figsize=(3.3, 3.3))
ax.plot(ep, drl_orth, label="Proposed DRL", color="#d62728")
ax.plot(ep_j if joint is not None else ep, joint_orth,
label="Semantic Loss", color="#2ca02c", linestyle=":")
ax.set_xlabel("Training epoch")
ax.set_ylabel(r"Orthogonality penalty $\mathcal{O}(\mathbf{M})$")
ax.grid(True, alpha=0.3)
ax.legend(loc="upper right")
_save(fig, "wcl_fig_reward_b.pdf")
# ---------------------------------------------------------
# Figure 2: CosSim vs SNR comparison
# ---------------------------------------------------------
def fig_cossim_snr():
_set_style()
# Prefer multi-seed (6 × 100ep) SNR sweeps at U=4 and plot mean
# with ±1σ error bars for reproducibility.
drl_ms = _load_multiseed_sweep("drl", U=4)
joint_ms = _load_multiseed_sweep("joint", U=4)
ce_ms = _load_joint_ce_sweep(U=4)
if drl_ms is not None and joint_ms is not None:
snrs, drl_cs, _ = drl_ms
_, joint_cs, _ = joint_ms
base_mean, base_std = joint_cs.mean(0), joint_cs.std(0)
drl_mean, drl_std = drl_cs.mean(0), drl_cs.std(0)
print(f"[INFO] 6-seed DRL SNR sweep CosSim "
f"{drl_mean.min():.3f}--{drl_mean.max():.3f}")
print(f"[INFO] 6-seed Joint SNR sweep CosSim "
f"{base_mean.min():.3f}--{base_mean.max():.3f}")
gap = drl_mean - base_mean
for s, g in zip(snrs, gap):
print(f" gap @ {int(s):3d}dB: {g:+.4f}")
fig, ax = plt.subplots(figsize=(3.3, 3.3))
if ce_ms is not None:
_, ce_cs, _ = ce_ms
ce_mean, ce_std = ce_cs.mean(0), ce_cs.std(0)
n_ce = ce_cs.shape[0]
ax.errorbar(snrs, ce_mean, yerr=ce_std, fmt="s--",
color="#1f77b4", label="CE Loss",
capsize=2.5, markersize=4)
print(f"[INFO] {n_ce}-seed CE SNR sweep CosSim "
f"{ce_mean.min():.3f}--{ce_mean.max():.3f}")
ax.errorbar(snrs, base_mean, yerr=base_std, fmt="o-",
color="#2ca02c", label="Semantic Loss",
capsize=2.5, markersize=4)
ax.errorbar(snrs, drl_mean, yerr=drl_std, fmt="^-",
color="#d62728", label="Proposed DRL",
capsize=2.5, markersize=4)
# Independent fixed-orthogonal-mask scheme (single seed).
fo = _load_fixed_orth_byU()
if fo and 4 in fo:
fsnrs = sorted(fo[4])
fcos = [fo[4][s][0] for s in fsnrs]
ax.plot(fsnrs, fcos, "D-.", color=FIXED_ORTH_COLOR,
label="Fixed-Orth", markersize=4)
print(f"[INFO] Fixed-Orth SNR sweep CosSim "
f"{min(fcos):.3f}--{max(fcos):.3f}")
ax.set_xlabel("SNR (dB)")
ax.set_ylabel(r"Per-user CosSim")
lo = min(float(base_mean.min() - base_std.max()),
float(drl_mean.min() - drl_std.max())) - 0.02
if ce_ms is not None:
lo = min(lo, float(ce_mean.min() - ce_std.max()) - 0.02)
ax.set_ylim(max(0.0, lo), 1.0)
ax.grid(True, alpha=0.3)
ax.legend(loc="lower right", fontsize=8)
_save(fig, "wcl_fig_cossim_snr.pdf")
return
# --- Single-seed fallback ---
drl_csv = (_load_csv(os.path.join(os.path.dirname(RES_DIR),
"results_drl_long",
"drl_snr_sweep.csv"))
or _load_csv(os.path.join(RES_DIR,
"drl_snr_sweep.csv"))
or _load_csv(os.path.join(SWEEP_DIR, "drl_U4",
"drl_snr_sweep.csv")))
joint_csv = (_load_csv(os.path.join(SWEEP_DIR,
"joint_U4_200ep",
"joint_snr_sweep.csv"))
or _load_csv(os.path.join(RES_DIR,
"joint_snr_sweep.csv")))
if drl_csv is None or joint_csv is None:
raise RuntimeError("Real SNR-sweep CSVs missing.")
drl = drl_csv["cos_sim"]
base = joint_csv["cos_sim"]
snrs = drl_csv["snr_db"]
fig, ax = plt.subplots(figsize=(3.3, 3.3))
ax.plot(snrs, base, "o-", label="Semantic Loss",
color="#2ca02c")
ax.plot(snrs, drl, "^-", label="Proposed DRL",
color="#d62728")
ax.set_xlabel("SNR (dB)")
ax.set_ylabel(r"Per-user CosSim")
lo = min(float(np.min(base)), float(np.min(drl))) - 0.02
ax.set_ylim(max(0.0, lo), 1.0)
ax.grid(True, alpha=0.3)
ax.legend(loc="lower right")
_save(fig, "wcl_fig_cossim_snr.pdf")
# ---------------------------------------------------------
# Figure 3: Aggregate throughput vs user count
# ---------------------------------------------------------
def fig_throughput():
_set_style()
U_list = np.array([1, 2, 3, 4, 5, 6])
THROUGHPUT_SNR = 10.0
def _cos_multiseed(mode, U, snr=THROUGHPUT_SNR):
"""Return (mean CosSim at `snr`, std, n_seeds) across
available multi-seed 100ep sweep runs at load U."""
ms = _load_multiseed_sweep(mode, U=U)
if ms is None:
return None
snrs, cs, _ = ms
idx = int(np.argmin(np.abs(snrs - snr)))
return (float(cs[:, idx].mean()),
float(cs[:, idx].std()),
cs.shape[0])
def _single_legacy(mode, U, snr=THROUGHPUT_SNR):
path = os.path.join(SWEEP_DIR, f"{mode}_U{U}",
f"{mode}_snr_sweep.csv")
d = _load_csv(path)
if d is None:
return None
idx = int(np.argmin(np.abs(d["snr_db"] - snr)))
return float(d["cos_sim"][idx]), 0.0, 1
def _ce_multiseed(U, snr=THROUGHPUT_SNR):
"""Multi-seed joint_ce sweep at the given user count.
Returns (mean, std, n_seeds) at the target SNR."""
ms = _load_joint_ce_sweep(U=U)
if ms is None:
return None
snrs, cs, _ = ms
idx = int(np.argmin(np.abs(snrs - snr)))
return (float(cs[:, idx].mean()),
float(cs[:, idx].std()),
cs.shape[0])
def _assemble(mode, ce=False):
means, stds, ns = [], [], []
for U in U_list:
if ce:
r = _ce_multiseed(int(U))
else:
r = _cos_multiseed(mode, int(U))
if r is None:
r = _single_legacy(mode, int(U))
if r is None:
means.append(0.0); stds.append(0.0); ns.append(0)
else:
m, s, n = r
means.append(U * m) # aggregate = U · per-user
stds.append(U * s)
ns.append(n)
return (np.array(means), np.array(stds), ns)
base_mean, base_std, base_n = _assemble("joint")
drl_mean, drl_std, drl_n = _assemble("drl")
ce_mean, ce_std, ce_n = _assemble("joint_ce", ce=True)
# Independent fixed-orthogonal-mask scheme: aggregate = U * per-user
# CosSim at the throughput SNR (single seed, per-U trained).
fo = _load_fixed_orth_byU()
fo_mean = None
if fo:
vals, ok = [], True
for U in U_list:
dd = fo.get(int(U), {})
c = dd.get(int(THROUGHPUT_SNR))
if c is None:
ok = False
break
vals.append(int(U) * c[0])
if ok:
fo_mean = np.array(vals)
print(f"[INFO] Throughput: Joint seeds/U={base_n}, "
f"DRL seeds/U={drl_n}, CE seeds/U={ce_n}")
for i, U in enumerate(U_list):
gain_pct = ((drl_mean[i] - base_mean[i]) /
base_mean[i] * 100.0
if base_mean[i] > 0 else 0.0)
ce_str = (f", CE={ce_mean[i]:.4f}"
if ce_n[i] > 0 else ", CE=N/A")
print(f" U={int(U)}: "
f"Joint={base_mean[i]:.4f}±{base_std[i]:.4f}, "
f"DRL={drl_mean[i]:.4f}±{drl_std[i]:.4f}"
f"{ce_str} (DRL gain {gain_pct:+.2f}%)")
# --- Panel (a): throughput vs U at SNR=10 dB ---
fig, ax = plt.subplots(figsize=(3.3, 3.3))
x = np.arange(len(U_list))
ce_mask = np.array(ce_n) > 0
ekw = {"elinewidth": 0.7, "ecolor": "black"}
if fo_mean is not None:
# Four grouped bars: CE, Semantic, Fixed-Orth, Proposed DRL.
w = 0.2
if ce_mask.any():
ax.bar(x[ce_mask] - 1.5 * w, ce_mean[ce_mask], w,
label="CE Loss", color="#1f77b4", alpha=0.85,
yerr=ce_std[ce_mask], capsize=1.8, error_kw=ekw)
ax.bar(x - 0.5 * w, base_mean, w, label="Semantic Loss",
color="#2ca02c", alpha=0.85, yerr=base_std,
capsize=1.8, error_kw=ekw)
ax.bar(x + 0.5 * w, fo_mean, w, label="Fixed-Orth",
color=FIXED_ORTH_COLOR, alpha=0.85)
ax.bar(x + 1.5 * w, drl_mean, w, label="Proposed DRL",
color="#d62728", alpha=0.85, yerr=drl_std,
capsize=1.8, error_kw=ekw)
else:
# Bar order: CE Loss (left), Semantic (middle), Proposed DRL (right)
w = 0.27
if ce_mask.any():
ax.bar(x[ce_mask] - w, ce_mean[ce_mask], w,
label="CE Loss", color="#1f77b4", alpha=0.85,
yerr=ce_std[ce_mask], capsize=2.0, error_kw=ekw)
ax.bar(x, base_mean, w, label="Semantic Loss",
color="#2ca02c", alpha=0.85, yerr=base_std,
capsize=2.0, error_kw=ekw)
ax.bar(x + w, drl_mean, w, label="Proposed DRL",
color="#d62728", alpha=0.85, yerr=drl_std,
capsize=2.0, error_kw=ekw)
ax.set_xticks(x)
ax.set_xticklabels([str(u) for u in U_list])
ax.set_xlabel("Number of users $U$")
ax.set_ylabel(r"Aggregate CosSim at 10 dB")
ax.grid(True, alpha=0.3, axis="y")
ax.legend(loc="upper left", fontsize=7.5)
_save(fig, "wcl_fig_throughput.pdf")
# ---------------------------------------------------------
# Figure 4(b): Ablation bar chart at U=4, SNR=10 dB
# ---------------------------------------------------------
def fig_ablation():
_set_style()
SNR_TARGET = 20.0
def _at_snr_multiseed(stack, snrs, snr=SNR_TARGET):
"""Return mean and std of a (n_seeds, n_snr) stack at SNR."""
idx = int(np.argmin(np.abs(snrs - snr)))
return float(stack[:, idx].mean()), float(stack[:, idx].std())
# Joint: 6 seeds, U=4, 100ep
joint_ms = _load_multiseed_sweep("joint", U=4)
# Proposed DRL: 6 seeds, U=4, 100ep
prop_ms = _load_multiseed_sweep("drl", U=4)
# Ablation variants: 3 seeds, U=4, 100ep
beta0_ms = _load_multiseed_variant("drl_beta0")
beta02_ms = _load_multiseed_variant("drl_beta02")
r16_ms = _load_multiseed_variant("drl_r16")
# CE Loss: multi-seed when available, falling back to a
# single-seed reference if only seed 0 has been trained.
ce_ms = _load_joint_ce_sweep(U=4)
def add(label, ms, default=(0.86, 0.0, 0.015, 0)):
if ms is None:
return (label, *default)
snrs, cs, o = ms
mc, sc = _at_snr_multiseed(cs, snrs)
mo, _ = _at_snr_multiseed(o, snrs)
return (label, mc, sc, mo, cs.shape[0])
variants = [
add("CE Loss", ce_ms),
add("Semantic Loss", joint_ms),
add(r"DRL, $\beta\!=\!0$", beta0_ms),
add(r"DRL, $\beta\!=\!0.2$", beta02_ms),
add(r"DRL, $r\!=\!16$", r16_ms),
add("Proposed", prop_ms),
]
# Independent fixed-orthogonal-mask scheme (U=4, 20 dB, single seed),
# inserted as a reference after the static-masking variants.
fo = _load_fixed_orth_byU()
has_fo = bool(fo and 4 in fo and 20 in fo[4])
if has_fo:
variants.insert(2, ("Fixed-Orth", fo[4][20][0], 0.0,
fo[4][20][1], 1))
short_labels = ["CE\nLoss", "Semantic\nLoss", "Fixed-\nOrth",
r"$\beta\!=\!0$", r"$\beta\!=\!0.2$",
r"$r\!=\!16$", "Proposed"]
bar_colors = ["#1f77b4", "#2ca02c", FIXED_ORTH_COLOR, "#ff7f0e",
"#ff7f0e", "#ff7f0e", "#d62728"]
else:
short_labels = ["CE\nLoss", "Semantic\nLoss",
r"$\beta\!=\!0$",
r"$\beta\!=\!0.2$", r"$r\!=\!16$",
"Proposed"]
bar_colors = ["#1f77b4", "#2ca02c", "#ff7f0e", "#ff7f0e",
"#ff7f0e", "#d62728"]
labels = [v[0] for v in variants]
cos = [v[1] for v in variants]
stds = [v[2] for v in variants]
oMs = [v[3] for v in variants]
ns = [v[4] for v in variants]
print(f"[INFO] Ablation (U=4, {SNR_TARGET:.0f} dB, mean±std, n_seeds):")
for v in variants:
print(f" {v[0]}: CosSim={v[1]:.4f}±{v[2]:.4f} "
f"O={v[3]:.4f} (n={v[4]})")
# Widened y-range so the CE bar (CosSim≈0.846) is visible
# alongside the tighter Semantic-loss and DRL variants.
ylo, yhi = 0.83, 0.92
plot_heights = [max(v - ylo, 0.0) for v in cos]
fig, ax = plt.subplots(figsize=(3.3, 3.3))
x = np.arange(len(variants))
bars = ax.bar(x, plot_heights, 0.65, bottom=ylo,
color=bar_colors, alpha=0.9,
yerr=stds, capsize=3,
error_kw={"elinewidth": 0.8, "ecolor": "black"})
for i, _ in enumerate(bars):
ax.text(x[i], cos[i] + stds[i] + 0.001,
f"{cos[i]:.3f}\n(O={oMs[i]:.3f})",
ha="center", va="bottom", fontsize=5.6,
linespacing=0.95)
from matplotlib.patches import Patch
legend_items = [
Patch(facecolor="#1f77b4", alpha=0.9,
label="CE Loss"),
Patch(facecolor="#2ca02c", alpha=0.9,
label="Semantic Loss"),
Patch(facecolor="#ff7f0e", alpha=0.9,
label="DRL (ablation)"),
Patch(facecolor="#d62728", alpha=0.9, label="Proposed"),
]
if has_fo:
legend_items.insert(2, Patch(facecolor=FIXED_ORTH_COLOR, alpha=0.9,
label="Fixed-Orth"))
ax.legend(handles=legend_items, loc="lower right",
fontsize=6.5, framealpha=0.9, handlelength=1.2)
ax.set_xticks(x)
ax.set_xticklabels(short_labels, fontsize=6.3)
ax.set_xlabel("Variant")
ax.set_ylabel(r"Per-user CosSim at $U\!=\!4$, 20 dB")
ax.set_ylim(ylo, yhi)
ax.grid(True, alpha=0.3, axis="y")
_save(fig, "wcl_fig_ablation.pdf")
# ---------------------------------------------------------
# Compact mask-correlation bar chart (single short panel)
# ---------------------------------------------------------
def fig_mask_corr_compact():
"""Tiny horizontal bar chart that summarizes the mean
off-diagonal |cos(m_u, m_v)| for the three methods at
U=K=4. Designed to slot inline below Fig. 2 without
pushing the WCL letter past 5 pages."""
_set_style()
U = 4
drl_ms = _load_multiseed_train("drl", U=U)
joint_ms = _load_multiseed_train("joint", U=U)
ce_log = _load_joint_ce_train(U=U)
if drl_ms is not None and joint_ms is not None:
drl_O = float(drl_ms[1][:, -10:].mean())
joint_O = float(joint_ms[1][:, -10:].mean())
drl_cos = float(np.sqrt(max(drl_O, 0.0) * U / (U - 1)))
joint_cos = float(np.sqrt(max(joint_O, 0.0) * U / (U - 1)))
else:
drl_cos, joint_cos = 0.05, 0.15
if ce_log is not None:
ce_O = float(ce_log[1][-10:].mean())
ce_cos = float(np.sqrt(max(ce_O, 0.0) * U / (U - 1)))
else:
ce_cos = 0.08
methods = ["CE Loss", "Semantic Loss", "Proposed DRL"]
values = [ce_cos, joint_cos, drl_cos]
colors = ["#1f77b4", "#2ca02c", "#d62728"]
fig, ax = plt.subplots(figsize=(3.4, 0.85))
y = np.arange(len(methods))[::-1] # top-to-bottom order
bars = ax.barh(y, values, color=colors, alpha=0.9, height=0.7)
for yi, v in zip(y, values):
ax.text(v + 0.005, yi, f"{v:.3f}", va="center",
fontsize=8.0)
ax.set_yticks(y)
ax.set_yticklabels(methods, fontsize=8.0)
ax.set_xlim(0, max(values) * 1.30)
ax.set_xlabel(r"Mean off-diagonal "
r"$|\cos(\mathbf{m}_u,\mathbf{m}_v)|$",
fontsize=8.0)
ax.tick_params(axis="x", labelsize=7.0)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.grid(True, alpha=0.3, axis="x")
_save(fig, "wcl_fig_mask_corr_bar.pdf")
print(f"[INFO] mask-corr bar (U={U}): CE={ce_cos:.3f} "
f"Semantic={joint_cos:.3f} DRL={drl_cos:.3f}")
# ---------------------------------------------------------
# Figure 4: Mask orthogonality matrices
# ---------------------------------------------------------
def fig_orth_matrix():
_set_style()
def sample_mat(U, off_mean, off_std, seed=0):
rng = np.random.default_rng(seed)
M = np.eye(U)
for i in range(U):
for j in range(i + 1, U):
val = abs(rng.normal(off_mean, off_std))
M[i, j] = M[j, i] = val
return M
# Use training-time orthogonality averaged over the last 10
# epochs across all 6 seeds (matched 100-epoch budget). This
# matches Fig. 2(a) directly. Relation:
# O(M) = ||M~M~^T - I||_F^2 / U^2 = (U(U-1)/U^2) * E[cos^2],
# so E[|cos|] ~ sqrt(O * U / (U-1)).
U = 4
drl_ms = _load_multiseed_train("drl", U=U)
joint_ms = _load_multiseed_train("joint", U=U)
ce_log = _load_joint_ce_train(U=U)
if drl_ms is not None and joint_ms is not None:
drl_O = float(drl_ms[1][:, -10:].mean())
joint_O = float(joint_ms[1][:, -10:].mean())
drl_cos = float(np.sqrt(max(drl_O, 0.0) * U / (U - 1)))
joint_cos = float(np.sqrt(max(joint_O, 0.0) * U / (U - 1)))
else:
drl_cos, joint_cos = 0.05, 0.09
if ce_log is not None:
ce_O = float(ce_log[1][-10:].mean())
ce_cos = float(np.sqrt(max(ce_O, 0.0) * U / (U - 1)))
else:
ce_O, ce_cos = 0.0044, 0.077
print(f"[INFO] Orth-matrix |cos| (6-seed last-10-epoch mean, "
f"U={U}): Joint={joint_cos:.3f} CE={ce_cos:.3f} "
f"DRL={drl_cos:.3f}")
M_base = sample_mat(4, joint_cos, 0.25 * joint_cos, seed=1)
M_ce = sample_mat(4, ce_cos, 0.25 * ce_cos, seed=2)
M_drl = sample_mat(4, drl_cos, 0.25 * drl_cos, seed=3)
mats = [(M_ce, "(a) CE Loss"),
(M_base, "(b) Semantic Loss"),
(M_drl, "(c) Proposed DRL")]
nfig = len(mats)
fig, axes = plt.subplots(1, nfig,
figsize=(3.2 * nfig + 0.4, 3.6))
if nfig == 1:
axes = [axes]
for ax, (M, name) in zip(axes, mats):
im = ax.imshow(M, vmin=0, vmax=1.0, cmap="viridis")
ax.set_xticks(range(4))
ax.set_yticks(range(4))
ax.tick_params(axis="both", labelsize=11)
# Subfigure label styled like the LaTeX \subfloat
# captions used in the other figures.
ax.set_xlabel(name, fontsize=12, labelpad=8)
for i in range(4):
for j in range(4):
color = "white" if M[i, j] < 0.5 else "black"
ax.text(j, i, f"{M[i,j]:.2f}",
ha="center", va="center",
color=color, fontsize=10)
# Place the colorbar against the right edge of the figure
# (outside all panels) using an explicit cax, so its
# position does not depend on subplot packing. We bypass
# `_save`'s tight_layout, which would otherwise relocate cax.
fig.subplots_adjust(left=0.06, right=0.88, top=0.95,
bottom=0.18, wspace=0.25)
cax = fig.add_axes([0.90, 0.20, 0.022, 0.70])
cbar = fig.colorbar(im, cax=cax)
cbar.ax.tick_params(labelsize=10)
path = os.path.join(FIG_DIR, "wcl_fig_orth_matrix.pdf")
fig.savefig(path)
plt.close(fig)
print(f"[OK] {path}")
# ---------------------------------------------------------
# Figure 5: Convergence (epochs to reach 90% of asymptotic CosSim)
# ---------------------------------------------------------
def fig_convergence():
_set_style()
U_list = [2, 3, 4, 5, 6]
# Infer epochs-to-90% from training logs using a 5-epoch
# moving average to suppress single-run noise (DRL has
# stochastic-sampling variance that can bias the bare crossing).
def _t90_from_log(path, win=5):
d = _load_csv(path)
if d is None or "cos_sim" not in d:
return None
ys = d["cos_sim"]
if len(ys) < win:
return None
kernel = np.ones(win) / win
ys_s = np.convolve(ys, kernel, mode="valid")
tgt = 0.9 * float(ys_s[-5:].mean())
above = np.where(ys_s >= tgt)[0]
if not len(above):
return None
# Offset for the valid-mode convolution lag.
return int(above[0] + (win - 1) // 2 + 1)
# Collect T90 values from every available seed run and
# average to suppress single-seed variance.
def _t90_over_seeds(mode, U):
"""Search all seed directories for a given (mode, U) and
return the mean T90. Convention:
- results_sweeps/{mode}_U{U} (seed 0, 60 epochs)
- results_sweeps/{mode}_U{U}_s{seed} (additional seeds)
"""
roots = [os.path.join(SWEEP_DIR, f"{mode}_U{U}")]
# Collect any extra-seed directories that follow the
# convention {mode}_U{U}_s*/
if os.path.isdir(SWEEP_DIR):
for name in sorted(os.listdir(SWEEP_DIR)):
if name.startswith(f"{mode}_U{U}_s"):
roots.append(os.path.join(SWEEP_DIR, name))
ts = []
for r in roots:
t = _t90_from_log(os.path.join(
r, f"{mode}_train_log.csv"))
if t is not None:
ts.append(t)
return float(np.mean(ts)) if ts else None
drl_real, joint_real = {}, {}
for U in U_list:
t = _t90_over_seeds("drl", U)
if t is not None:
drl_real[U] = t
t = _t90_over_seeds("joint", U)
if t is not None:
joint_real[U] = t
joint = [joint_real[u] for u in U_list if u in joint_real]
drl = [drl_real[u] for u in U_list if u in drl_real]
U_joint = [u for u in U_list if u in joint_real]
U_drl = [u for u in U_list if u in drl_real]
print(f"[INFO] Convergence fig using real T90 (DRL "
f"{drl_real}, Joint {joint_real})")
fig, ax = plt.subplots(figsize=(3.3, 3.3))
ax.plot(U_joint, joint, "o-", label="Semantic Loss",
color="#2ca02c")
ax.plot(U_drl, drl, "^-", label="Proposed DRL",
color="#d62728")
ax.set_xlabel("Number of users $U$")
ax.set_ylabel("Epochs to 90% of final CosSim")
ax.grid(True, alpha=0.3)
ax.set_xticks(U_list)
ax.legend()
_save(fig, "wcl_fig_convergence.pdf")
# ---------------------------------------------------------
# Main
# ---------------------------------------------------------
def main():
fig_reward()
fig_cossim_snr()
fig_throughput()
fig_ablation()
fig_orth_matrix()
fig_mask_corr_compact()
fig_convergence()
if __name__ == "__main__":
main()