b-load 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/analysis.py ADDED
@@ -0,0 +1,805 @@
1
+ """
2
+ analysis.py — Result loading, aggregation, and visualisation utilities.
3
+
4
+ This module centralises all post-processing logic that was previously
5
+ embedded in the synthetic_results analysis notebook. It covers four
6
+ concerns:
7
+
8
+ 1. **Result loading** — reading experiment CSV files produced by the
9
+ experiment scripts, including the special "100-iter" parallelised
10
+ format where each configuration is split across multiple job files.
11
+
12
+ 2. **Complexity metrics and CIs** — computing the statistical-power
13
+ metric π = n_samples / (expected_edges / 2) and 95 % confidence
14
+ intervals for every dataframe.
15
+
16
+ 3. **Regime tables** — aggregating mean F1 and intervention distance
17
+ across Low / Medium / High (or Low / High) complexity regimes for
18
+ all six methods (PC, b-PC local, b-PC global, LOAD, b-LOAD local,
19
+ b-LOAD global).
20
+
21
+ 4. **Graphical analysis** — publication-ready F1 score plots (2 × 2
22
+ grid over BK fractions) and intervention-distance panels (three
23
+ views: data efficiency, BK sensitivity, scalability).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import glob
29
+ import os
30
+ import re
31
+ from collections import defaultdict
32
+ from typing import Optional, Sequence
33
+
34
+ import matplotlib.pyplot as plt
35
+ import numpy as np
36
+ import pandas as pd
37
+
38
+ # ─────────────────────────────────────────────────────────────────────────────
39
+ # Constants
40
+ # ─────────────────────────────────────────────────────────────────────────────
41
+
42
+ N_ITER = 1000 # Total iterations assumed for CI computation (1000 across all jobs)
43
+
44
+ # Column groups describing the statistics to aggregate for each df type
45
+ PC_STAT_PAIRS: list[tuple[str, str]] = [
46
+ ("f1_mean", "f1_std"),
47
+ ("int_dist_mean", "int_dist_std"),
48
+ ]
49
+ """Stat-column pairs (mean, std) for PC-style result dataframes."""
50
+
51
+ LOAD_STAT_PAIRS: list[tuple[str, str]] = [
52
+ ("f1_with_b_mean", "f1_with_b_std"),
53
+ ("f1_without_b_mean", "f1_without_b_std"),
54
+ ("int_dist_with_b_mean", "int_dist_with_b_std"),
55
+ ("int_dist_without_b_mean","int_dist_without_b_std"),
56
+ ]
57
+ """Stat-column pairs (mean, std) for LOAD-style result dataframes."""
58
+
59
+ # Colour palette shared by all plots (consistent across F1 and int-dist figures)
60
+ PLOT_COLORS: dict[str, str] = {
61
+ "pc": "#888888", # grey — PC (no BK)
62
+ "bpc": "#d62728", # red — b-PC (local BK)
63
+ "bpc_glob": "#9467bd", # purple — b-PC (global BK)
64
+ "load": "#1f77b4", # blue — LOAD (no BK)
65
+ "bload_local": "#2ca02c", # green — b-LOAD (local BK)
66
+ "bload_glob": "#ff7f0e", # orange — b-LOAD (global BK)
67
+ }
68
+
69
+
70
+ # ─────────────────────────────────────────────────────────────────────────────
71
+ # 1. Result loading
72
+ # ─────────────────────────────────────────────────────────────────────────────
73
+
74
+ def combine_stats(
75
+ job_results: list[tuple[int, float, float]],
76
+ ) -> tuple[float, float]:
77
+ """Combine per-job statistics into a single global mean and std.
78
+
79
+ Uses the exact parallel-variance formula (law of total variance) so that
80
+ the result is mathematically identical to computing the mean and std over
81
+ the full pooled sample. This is required because the experiments were
82
+ parallelised: each configuration was split into multiple job files of
83
+ ``n_per_iter`` iterations each, and their statistics cannot be averaged
84
+ naively.
85
+
86
+ Args:
87
+ job_results: List of ``(n, mean, var)`` tuples, one per job file,
88
+ where ``n`` is the number of iterations in that file, ``mean`` is
89
+ the per-job sample mean, and ``var`` is the per-job sample
90
+ variance (i.e., ``std ** 2``).
91
+
92
+ Returns:
93
+ ``(global_mean, global_std)`` — the combined mean and standard
94
+ deviation over all ``N = sum(n_j)`` iterations.
95
+
96
+ Example::
97
+
98
+ >>> jobs = [(100, 0.8, 0.01), (100, 0.75, 0.02)]
99
+ >>> combine_stats(jobs)
100
+ (0.775, ...)
101
+ """
102
+ N = sum(n for n, _, _ in job_results)
103
+ global_mean = sum(n * m for n, m, _ in job_results) / N
104
+ combined_var = (
105
+ sum((n - 1) * v + n * (m - global_mean) ** 2 for n, m, v in job_results)
106
+ / (N - 1)
107
+ )
108
+ return global_mean, float(np.sqrt(combined_var))
109
+
110
+
111
+ def load_iter_folder(
112
+ folder: str,
113
+ stat_col_pairs: list[tuple[str, str]],
114
+ n_per_iter: int = 100,
115
+ ) -> pd.DataFrame:
116
+ """Load a parallelised "100-iter" experiment folder into a single DataFrame.
117
+
118
+ In the 100-iter format, each graph configuration is stored as multiple
119
+ CSV files named ``<config>_iter1.csv``, ``<config>_iter2.csv``, … Each
120
+ file contains results for ``n_per_iter`` independent repetitions. This
121
+ function groups files by their base config name, aggregates the statistics
122
+ across all iter files using :func:`combine_stats`, and returns one
123
+ aggregated row per configuration.
124
+
125
+ Args:
126
+ folder: Path to the directory containing the ``_iter{n}.csv`` files.
127
+ stat_col_pairs: List of ``(mean_col, std_col)`` pairs identifying
128
+ which columns to aggregate. Non-statistic columns (e.g. graph
129
+ parameters) are taken verbatim from the first iter file.
130
+ n_per_iter: Number of iterations stored in each job file.
131
+ Defaults to 100.
132
+
133
+ Returns:
134
+ DataFrame with one aggregated row per configuration. Returns an
135
+ empty DataFrame and prints a warning if the folder contains no files.
136
+ """
137
+ files = sorted(glob.glob(os.path.join(folder, "*.csv")))
138
+ if not files:
139
+ print(f" WARNING: no CSV files found in {folder}")
140
+ return pd.DataFrame()
141
+
142
+ # Group files by base config name (strip _iter{n}.csv suffix)
143
+ groups: dict[str, list[str]] = defaultdict(list)
144
+ for f in files:
145
+ base = re.sub(r"_iter\d+\.csv$", "", os.path.basename(f))
146
+ groups[base].append(f)
147
+
148
+ rows = []
149
+ for base, flist in sorted(groups.items()):
150
+ iter_dfs = [pd.read_csv(f) for f in sorted(flist)]
151
+ row = iter_dfs[0].iloc[0].to_dict() # metadata from first iter file
152
+ for mean_col, std_col in stat_col_pairs:
153
+ job_results = [
154
+ (n_per_iter, df[mean_col].iloc[0], df[std_col].iloc[0] ** 2)
155
+ for df in iter_dfs
156
+ ]
157
+ row[mean_col], row[std_col] = combine_stats(job_results)
158
+ rows.append(row)
159
+
160
+ n_iters = max(len(v) for v in groups.values())
161
+ print(f" {n_iters} iters × {len(rows)} configs ← {os.path.basename(folder)}")
162
+ return pd.DataFrame(rows)
163
+
164
+
165
+ def load_standard_folder(pattern: str) -> pd.DataFrame:
166
+ """Load a standard (non-parallelised) experiment folder by glob pattern.
167
+
168
+ A "standard" folder stores one CSV per configuration, each already
169
+ containing the final aggregated statistics from all iterations.
170
+
171
+ Args:
172
+ pattern: Glob pattern (e.g. ``'../experiment_runs/pc/*.csv'``).
173
+
174
+ Returns:
175
+ Concatenated DataFrame of all matching CSVs. Returns an empty
176
+ DataFrame if no files match.
177
+ """
178
+ files = glob.glob(pattern)
179
+ if not files:
180
+ print(f" WARNING: no files matching {pattern}")
181
+ return pd.DataFrame()
182
+ return pd.concat([pd.read_csv(f) for f in files], ignore_index=True)
183
+
184
+
185
+ # ─────────────────────────────────────────────────────────────────────────────
186
+ # 2. Complexity metrics and confidence intervals
187
+ # ─────────────────────────────────────────────────────────────────────────────
188
+
189
+ def add_complexity_metric(df: pd.DataFrame) -> pd.DataFrame:
190
+ """Add the statistical-power metric π to a result DataFrame (in-place).
191
+
192
+ π is defined as::
193
+
194
+ π = n_samples / ((observed × exp_degree) / 2)
195
+
196
+ which equals the ratio of available samples to the expected number of
197
+ edges in the graph. It serves as the x-axis for all F1 plots.
198
+
199
+ Args:
200
+ df: DataFrame containing at least the columns ``n_samples``,
201
+ ``observed``, and ``exp_degree``.
202
+
203
+ Returns:
204
+ The same DataFrame with a new ``complexity_metric`` column added.
205
+ """
206
+ df["complexity_metric"] = df["n_samples"] / ((df["observed"] * df["exp_degree"]) / 2)
207
+ return df
208
+
209
+
210
+ def add_confidence_intervals_pc(df: pd.DataFrame, n_iter: int = N_ITER) -> pd.DataFrame:
211
+ """Add 95 % confidence-interval columns to a PC-style result DataFrame.
212
+
213
+ Adds ``ci_f1`` and ``ci_int_dist`` columns computed as
214
+ ``1.96 × std / √n_iter``.
215
+
216
+ Args:
217
+ df: DataFrame with ``f1_std`` and ``int_dist_std`` columns.
218
+ n_iter: Total number of iterations used to produce each row.
219
+ Defaults to :data:`N_ITER` (1000).
220
+
221
+ Returns:
222
+ The same DataFrame with CI columns added in-place.
223
+ """
224
+ df["ci_f1"] = 1.96 * df["f1_std"] / np.sqrt(n_iter)
225
+ df["ci_int_dist"] = 1.96 * df["int_dist_std"] / np.sqrt(n_iter)
226
+ return df
227
+
228
+
229
+ def add_confidence_intervals_load(df: pd.DataFrame, n_iter: int = N_ITER) -> pd.DataFrame:
230
+ """Add 95 % confidence-interval columns to a LOAD-style result DataFrame.
231
+
232
+ Adds CI columns for both the with-BK and without-BK variants of F1 and
233
+ intervention distance.
234
+
235
+ Args:
236
+ df: DataFrame with ``f1_with_b_std``, ``f1_without_b_std``,
237
+ ``int_dist_with_b_std``, and ``int_dist_without_b_std`` columns.
238
+ n_iter: Total number of iterations per row (default 1000).
239
+
240
+ Returns:
241
+ The same DataFrame with four new CI columns added in-place.
242
+ """
243
+ df["ci_f1_with_b"] = 1.96 * df["f1_with_b_std"] / np.sqrt(n_iter)
244
+ df["ci_f1_without_b"] = 1.96 * df["f1_without_b_std"] / np.sqrt(n_iter)
245
+ df["ci_int_dist_with_b"] = 1.96 * df["int_dist_with_b_std"] / np.sqrt(n_iter)
246
+ df["ci_int_dist_without_b"] = 1.96 * df["int_dist_without_b_std"] / np.sqrt(n_iter)
247
+ return df
248
+
249
+
250
+ def make_load_no_bk_baseline(df_load: pd.DataFrame) -> pd.DataFrame:
251
+ """Extract the BK-independent LOAD baseline from a LOAD result DataFrame.
252
+
253
+ The ``f1_without_b_mean`` metric is identical across all
254
+ ``fraction_required`` values for the same ``(observed, exp_degree,
255
+ n_samples)`` configuration. This function deduplicates on those three
256
+ keys and returns a lean DataFrame used as the LOAD (no-BK) reference
257
+ series in plots.
258
+
259
+ Args:
260
+ df_load: Full LOAD result DataFrame (with BK columns).
261
+
262
+ Returns:
263
+ Deduplicated DataFrame containing only the BK-independent columns.
264
+ """
265
+ cols = [
266
+ "observed", "exp_degree", "n_samples", "complexity_metric",
267
+ "f1_without_b_mean", "ci_f1_without_b",
268
+ "int_dist_without_b_mean", "ci_int_dist_without_b",
269
+ ]
270
+ return (
271
+ df_load
272
+ .drop_duplicates(subset=["observed", "exp_degree", "n_samples"])
273
+ [cols]
274
+ .copy()
275
+ )
276
+
277
+
278
+ # ─────────────────────────────────────────────────────────────────────────────
279
+ # 3. Regime tables
280
+ # ─────────────────────────────────────────────────────────────────────────────
281
+
282
+ def _bin_3(pi: float) -> str:
283
+ """Map π to a three-way complexity regime label."""
284
+ if pi < 10: return "Low"
285
+ if pi < 100: return "Medium"
286
+ return "High"
287
+
288
+
289
+ def _bin_2(pi: float) -> str:
290
+ """Map π to a two-way complexity regime label (threshold at π = 50)."""
291
+ return "Low" if pi < 50 else "High"
292
+
293
+
294
+ def _extract_regime_row(
295
+ df: pd.DataFrame,
296
+ method_name: str,
297
+ f1_col: str,
298
+ id_col: str,
299
+ regimes: list[str],
300
+ bin_fn,
301
+ ) -> dict:
302
+ """Build one summary row by averaging over each complexity regime.
303
+
304
+ Args:
305
+ df: Result DataFrame for one method, already containing
306
+ ``complexity_metric``.
307
+ method_name: Display name for the method (used in the table).
308
+ f1_col: Column name for the F1 mean.
309
+ id_col: Column name for the intervention-distance mean.
310
+ regimes: Ordered list of regime labels (e.g. ``['Low','Medium','High']``).
311
+ bin_fn: Callable mapping a π value to a regime label.
312
+
313
+ Returns:
314
+ Dict with ``'Method'`` plus ``'{regime} F1'`` and
315
+ ``'{regime} Int. Dist.'`` keys for each regime.
316
+ """
317
+ tmp = df.copy()
318
+ tmp["regime"] = tmp["complexity_metric"].apply(bin_fn)
319
+ row: dict = {"Method": method_name}
320
+ for r in regimes:
321
+ sub = tmp[tmp["regime"] == r]
322
+ row[f"{r} F1"] = sub[f1_col].mean() if not sub.empty else np.nan
323
+ row[f"{r} Int. Dist."] = sub[id_col].mean() if not sub.empty else np.nan
324
+ return row
325
+
326
+
327
+ def _append_best_row(summary: pd.DataFrame, regimes: list[str]) -> pd.DataFrame:
328
+ """Append a 'Best Model' row identifying the winner per regime and metric."""
329
+ best: dict = {"Method": "Best Model"}
330
+ for r in regimes:
331
+ fc, ic = f"{r} F1", f"{r} Int. Dist."
332
+ best[fc] = summary.loc[summary[fc].idxmax(), "Method"] if summary[fc].notna().any() else None
333
+ best[ic] = summary.loc[summary[ic].idxmin(), "Method"] if summary[ic].notna().any() else None
334
+ final = pd.concat([summary, pd.DataFrame([best])], ignore_index=True)
335
+ num = final.select_dtypes(include=[np.number]).columns
336
+ final[num] = final[num].round(4)
337
+ return final
338
+
339
+
340
+ def regime_table_3(
341
+ df_pc: pd.DataFrame,
342
+ df_bpc: pd.DataFrame,
343
+ df_bpc_global: pd.DataFrame,
344
+ df_load: pd.DataFrame,
345
+ df_bload_global: pd.DataFrame,
346
+ ) -> pd.DataFrame:
347
+ """Build a triple-regime (Low / Medium / High) summary table for all six methods.
348
+
349
+ Averages mean F1 score and mean intervention distance within each π
350
+ regime across the full dataset for each of the six comparison methods:
351
+ PC, b-PC (local), b-PC (global), LOAD, b-LOAD (local), b-LOAD (global).
352
+
353
+ Regime thresholds: Low (π < 10), Medium (10 ≤ π < 100), High (π ≥ 100).
354
+
355
+ Args:
356
+ df_pc: PC (no BK) result DataFrame.
357
+ df_bpc: b-PC local-BK result DataFrame.
358
+ df_bpc_global: b-PC global-BK result DataFrame.
359
+ df_load: LOAD / b-LOAD local-BK result DataFrame.
360
+ df_bload_global: b-LOAD global-BK result DataFrame.
361
+
362
+ Returns:
363
+ DataFrame with one row per method plus a 'Best Model' row, and
364
+ columns for F1 and intervention distance in each regime.
365
+ """
366
+ regimes = ["Low", "Medium", "High"]
367
+ df_load_base = df_load.drop_duplicates(subset=["observed", "exp_degree", "n_samples"])
368
+
369
+ records = [
370
+ _extract_regime_row(df_pc, "PC", "f1_mean", "int_dist_mean", regimes, _bin_3),
371
+ _extract_regime_row(df_bpc, "b-PC (local)", "f1_mean", "int_dist_mean", regimes, _bin_3),
372
+ _extract_regime_row(df_bpc_global, "b-PC (global)", "f1_mean", "int_dist_mean", regimes, _bin_3),
373
+ _extract_regime_row(df_load_base, "LOAD", "f1_without_b_mean", "int_dist_without_b_mean", regimes, _bin_3),
374
+ _extract_regime_row(df_load, "b-LOAD (local)", "f1_with_b_mean", "int_dist_with_b_mean", regimes, _bin_3),
375
+ _extract_regime_row(df_bload_global,"b-LOAD (global)","f1_with_b_mean", "int_dist_with_b_mean", regimes, _bin_3),
376
+ ]
377
+ return _append_best_row(pd.DataFrame(records), regimes)
378
+
379
+
380
+ def regime_table_2(
381
+ df_pc: pd.DataFrame,
382
+ df_bpc: pd.DataFrame,
383
+ df_bpc_global: pd.DataFrame,
384
+ df_load: pd.DataFrame,
385
+ df_bload_global: pd.DataFrame,
386
+ ) -> pd.DataFrame:
387
+ """Build a double-regime (Low / High) summary table for all six methods.
388
+
389
+ Same structure as :func:`regime_table_3` but with a single threshold at
390
+ π = 50: Low (π < 50) vs High (π ≥ 50).
391
+
392
+ Args:
393
+ df_pc: PC (no BK) result DataFrame.
394
+ df_bpc: b-PC local-BK result DataFrame.
395
+ df_bpc_global: b-PC global-BK result DataFrame.
396
+ df_load: LOAD / b-LOAD local-BK result DataFrame.
397
+ df_bload_global: b-LOAD global-BK result DataFrame.
398
+
399
+ Returns:
400
+ DataFrame with one row per method plus a 'Best Model' row, and
401
+ columns for F1 and intervention distance in each regime.
402
+ """
403
+ regimes = ["Low", "High"]
404
+ df_load_base = df_load.drop_duplicates(subset=["observed", "exp_degree", "n_samples"])
405
+
406
+ records = [
407
+ _extract_regime_row(df_pc, "PC", "f1_mean", "int_dist_mean", regimes, _bin_2),
408
+ _extract_regime_row(df_bpc, "b-PC (local)", "f1_mean", "int_dist_mean", regimes, _bin_2),
409
+ _extract_regime_row(df_bpc_global, "b-PC (global)", "f1_mean", "int_dist_mean", regimes, _bin_2),
410
+ _extract_regime_row(df_load_base, "LOAD", "f1_without_b_mean", "int_dist_without_b_mean", regimes, _bin_2),
411
+ _extract_regime_row(df_load, "b-LOAD (local)", "f1_with_b_mean", "int_dist_with_b_mean", regimes, _bin_2),
412
+ _extract_regime_row(df_bload_global,"b-LOAD (global)","f1_with_b_mean", "int_dist_with_b_mean", regimes, _bin_2),
413
+ ]
414
+ return _append_best_row(pd.DataFrame(records), regimes)
415
+
416
+
417
+ # ─────────────────────────────────────────────────────────────────────────────
418
+ # 4. Graphical analysis helpers
419
+ # ─────────────────────────────────────────────────────────────────────────────
420
+
421
+ def agg_by_complexity(
422
+ df: pd.DataFrame,
423
+ mean_col: str,
424
+ ci_col: str,
425
+ cx_col: str = "complexity_metric",
426
+ n_bins: int = 40,
427
+ ) -> pd.DataFrame:
428
+ """Aggregate a result series into log-spaced complexity bins.
429
+
430
+ Groups rows into ``n_bins`` log-spaced bins of π, then averages the
431
+ ``mean_col`` and ``ci_col`` within each bin. This smooths the zigzag
432
+ pattern that arises from many discrete (n_samples, observed) combinations
433
+ mapping to similar but not identical π values.
434
+
435
+ Args:
436
+ df: DataFrame containing ``cx_col``, ``mean_col``, and ``ci_col``.
437
+ mean_col: Name of the metric mean column (e.g. ``'f1_mean'``).
438
+ ci_col: Name of the CI column (e.g. ``'ci_f1'``).
439
+ cx_col: Name of the complexity-metric column.
440
+ Defaults to ``'complexity_metric'``.
441
+ n_bins: Number of log-spaced bins. Defaults to 40.
442
+
443
+ Returns:
444
+ Aggregated DataFrame with one row per non-empty bin, sorted by π.
445
+ Columns: ``cx_col``, ``mean_col``, ``ci_col``.
446
+ """
447
+ lo = df[cx_col][df[cx_col] > 0].min()
448
+ hi = df[cx_col].max()
449
+ bins = np.logspace(np.log10(lo), np.log10(hi), n_bins + 1)
450
+ tmp = df.copy()
451
+ tmp["_bin"] = pd.cut(tmp[cx_col], bins=bins, labels=False, include_lowest=True)
452
+ tmp["_bin_mid"] = tmp["_bin"].map(
453
+ lambda b: np.sqrt(bins[int(b)] * bins[int(b) + 1]) if pd.notna(b) else np.nan
454
+ )
455
+ return (
456
+ tmp.dropna(subset=["_bin"])
457
+ .groupby("_bin_mid")[[mean_col, ci_col]]
458
+ .mean()
459
+ .reset_index()
460
+ .rename(columns={"_bin_mid": cx_col})
461
+ .sort_values(cx_col)
462
+ )
463
+
464
+
465
+ def _plot_line(
466
+ ax: plt.Axes,
467
+ df_agg: pd.DataFrame,
468
+ mean_col: str,
469
+ ci_col: str,
470
+ color: str,
471
+ label: str,
472
+ cx_col: str = "complexity_metric",
473
+ linestyle: str = "-",
474
+ marker: str = "o",
475
+ ci_alpha: float = 0.15,
476
+ ) -> None:
477
+ """Draw a line with shaded CI band on ``ax``."""
478
+ x = df_agg[cx_col]
479
+ y = df_agg[mean_col]
480
+ ci = df_agg[ci_col]
481
+ ax.plot(x, y, color=color, label=label,
482
+ linestyle=linestyle, marker=marker, markersize=5, linewidth=1.8)
483
+ ax.fill_between(x, y - ci, y + ci, color=color, alpha=ci_alpha)
484
+
485
+
486
+ def plot_f1(
487
+ df_pc: pd.DataFrame,
488
+ df_bpc: pd.DataFrame,
489
+ df_bpc_global: pd.DataFrame,
490
+ df_load: pd.DataFrame,
491
+ df_bload_global: pd.DataFrame,
492
+ df_load_no_bk: pd.DataFrame,
493
+ target_fracs: Sequence[float] = (0.2, 0.4, 0.6, 0.8),
494
+ save_path: Optional[str] = None,
495
+ ) -> plt.Figure:
496
+ """Plot F1 scores for all six methods as a 2 × 2 grid over BK fractions.
497
+
498
+ Each subplot corresponds to one value of ``fraction_required`` (the
499
+ fraction of known required edges). The x-axis is the complexity metric
500
+ π on a log scale; the y-axis is the mean F1 score with 95 % CI bands.
501
+
502
+ Requires ``scienceplots`` to be installed for publication-quality styling.
503
+
504
+ Args:
505
+ df_pc: PC result DataFrame (must have ``complexity_metric``,
506
+ ``ci_f1``, ``f1_mean``).
507
+ df_bpc: b-PC local result DataFrame.
508
+ df_bpc_global: b-PC global result DataFrame.
509
+ df_load: b-LOAD local result DataFrame (also provides b-LOAD baseline
510
+ via ``f1_with_b_mean``).
511
+ df_bload_global: b-LOAD global result DataFrame.
512
+ df_load_no_bk: BK-independent LOAD baseline DataFrame (from
513
+ :func:`make_load_no_bk_baseline`).
514
+ target_fracs: BK fractions to plot (one subplot each).
515
+ Must have exactly 4 values to fill the 2 × 2 grid.
516
+ save_path: If given, saves the figure to this path (e.g.
517
+ ``'f1_plot.pdf'``).
518
+
519
+ Returns:
520
+ The matplotlib Figure object.
521
+ """
522
+ try:
523
+ import scienceplots # noqa: F401
524
+ plt.style.use(["science", "no-latex"])
525
+ except ImportError:
526
+ pass # fall back to default style
527
+
528
+ C = PLOT_COLORS
529
+ agg_pc = agg_by_complexity(df_pc, mean_col="f1_mean", ci_col="ci_f1")
530
+ agg_lo = agg_by_complexity(df_load_no_bk, mean_col="f1_without_b_mean", ci_col="ci_f1_without_b")
531
+
532
+ fig, axes = plt.subplots(2, 2, figsize=(14, 10),
533
+ sharex=True, sharey=True, layout="constrained")
534
+ axes = axes.flatten()
535
+
536
+ for i, ax in enumerate(axes):
537
+ if i >= len(target_fracs):
538
+ ax.set_visible(False)
539
+ continue
540
+
541
+ frac = target_fracs[i]
542
+ sub_bpc = df_bpc [np.isclose(df_bpc ["fraction_required"], frac)]
543
+ sub_bpcg = df_bpc_global [np.isclose(df_bpc_global ["fraction_required"], frac)]
544
+ sub_lo = df_load [np.isclose(df_load ["fraction_required"], frac)]
545
+ sub_bg = df_bload_global[np.isclose(df_bload_global["fraction_required"], frac)]
546
+
547
+ _plot_line(ax, agg_pc, "f1_mean", "ci_f1",
548
+ C["pc"], "PC", linestyle="-", marker="o")
549
+ if not sub_bpc.empty:
550
+ _plot_line(ax, agg_by_complexity(sub_bpc, "f1_mean", "ci_f1"),
551
+ "f1_mean", "ci_f1", C["bpc"], "b-PC (local)", linestyle="-", marker="s")
552
+ if not sub_bpcg.empty:
553
+ _plot_line(ax, agg_by_complexity(sub_bpcg, "f1_mean", "ci_f1"),
554
+ "f1_mean", "ci_f1", C["bpc_glob"], "b-PC (global)", linestyle="-", marker="D")
555
+ _plot_line(ax, agg_lo, "f1_without_b_mean", "ci_f1_without_b",
556
+ C["load"], "LOAD", linestyle="--", marker="o")
557
+ if not sub_lo.empty:
558
+ _plot_line(ax, agg_by_complexity(sub_lo, "f1_with_b_mean", "ci_f1_with_b"),
559
+ "f1_with_b_mean", "ci_f1_with_b",
560
+ C["bload_local"], "b-LOAD (local)", linestyle="--", marker="s")
561
+ if not sub_bg.empty:
562
+ _plot_line(ax, agg_by_complexity(sub_bg, "f1_with_b_mean", "ci_f1_with_b"),
563
+ "f1_with_b_mean", "ci_f1_with_b",
564
+ C["bload_glob"], "b-LOAD (global)", linestyle="--", marker="^")
565
+
566
+ ax.set_title(f"Frac: {frac:.0%}", fontsize=20, pad=6, fontweight="bold")
567
+ ax.set_xscale("log")
568
+ ax.set_ylim(-0.05, 1.05)
569
+ ax.set_yticks(np.arange(0, 1.1, 0.2))
570
+ ax.tick_params(axis="both", which="major", labelsize=12)
571
+ ax.grid(True, which="major", linestyle="-", alpha=0.5)
572
+ ax.grid(True, which="minor", linestyle=":", alpha=0.3)
573
+
574
+ fig.supxlabel("Statistical Power Metric — log scale", fontsize=20, fontweight="bold")
575
+ fig.supylabel("F1 Score", fontsize=20, fontweight="bold")
576
+ handles, labels = axes[0].get_legend_handles_labels()
577
+ fig.legend(handles, labels, loc="outside upper center", ncol=6,
578
+ frameon=False, prop={"size": 18, "weight": "bold"})
579
+
580
+ if save_path:
581
+ fig.savefig(save_path, bbox_inches="tight")
582
+
583
+ return fig
584
+
585
+
586
+ def plot_intervention_distance(
587
+ df_pc: pd.DataFrame,
588
+ df_bpc: pd.DataFrame,
589
+ df_bpc_global: pd.DataFrame,
590
+ df_load: pd.DataFrame,
591
+ df_bload_global: pd.DataFrame,
592
+ exp_degree: float = 2.0,
593
+ frac: float = 0.3,
594
+ d_panels_a: Optional[Sequence[int]] = None,
595
+ n_panels_bc: Sequence[int] = (100, 1000, 5000, 10000),
596
+ d_panel_b: int = 10,
597
+ save_prefix: Optional[str] = None,
598
+ ) -> tuple[plt.Figure, plt.Figure, plt.Figure]:
599
+ """Produce three intervention-distance figures for all six methods.
600
+
601
+ All three figures restrict to a single expected-degree value (``exp_degree``,
602
+ default ER2).
603
+
604
+ - **Figure A** — Data efficiency: x-axis = ``n_samples``, panels = graph
605
+ sizes ``d``. BK fraction fixed at ``frac``.
606
+ - **Figure B** — BK sensitivity: x-axis = ``fraction_required``, panels =
607
+ ``n_samples``. Graph size fixed at ``d_panel_b``.
608
+ - **Figure C** — Scalability: x-axis = ``observed`` (graph size), panels
609
+ = ``n_samples``. BK fraction fixed at ``frac``.
610
+
611
+ Args:
612
+ df_pc: PC result DataFrame.
613
+ df_bpc: b-PC local result DataFrame.
614
+ df_bpc_global: b-PC global result DataFrame.
615
+ df_load: b-LOAD local (and LOAD baseline) result DataFrame.
616
+ df_bload_global: b-LOAD global result DataFrame.
617
+ exp_degree: Expected graph degree to filter on (default 2.0 = ER2).
618
+ frac: BK fraction used in panel A and C (default 0.3).
619
+ d_panels_a: Graph sizes used as panels in Figure A. Defaults to
620
+ all unique ``observed`` values in ``df_pc`` for ``exp_degree``.
621
+ n_panels_bc: Sample sizes used as panels in Figures B and C.
622
+ Defaults to ``[100, 1000, 5000, 10000]`` (filtered to available).
623
+ d_panel_b: Graph size fixed for Figure B (default 10).
624
+ save_prefix: If given, saves the three figures as
625
+ ``{save_prefix}_A.pdf``, ``{save_prefix}_B.pdf``,
626
+ ``{save_prefix}_C.pdf``.
627
+
628
+ Returns:
629
+ Tuple ``(fig_a, fig_b, fig_c)`` of matplotlib Figure objects.
630
+ """
631
+ C = PLOT_COLORS
632
+
633
+ # ── Filter to target exp_degree ───────────────────────────────────────────
634
+ er = exp_degree
635
+ pc_er = df_pc [np.isclose(df_pc ["exp_degree"], er)].copy()
636
+ bpc_er = df_bpc [np.isclose(df_bpc ["exp_degree"], er)].copy()
637
+ bpcg_er = df_bpc_global [np.isclose(df_bpc_global ["exp_degree"], er)].copy()
638
+ lo_er = df_load [np.isclose(df_load ["exp_degree"], er)].copy()
639
+ bg_er = df_bload_global[np.isclose(df_bload_global["exp_degree"], er)].copy()
640
+
641
+ all_n = sorted(
642
+ set(bpc_er["n_samples"].unique()) |
643
+ set(bpcg_er["n_samples"].unique()) |
644
+ set(bg_er["n_samples"].unique())
645
+ )
646
+
647
+ # ── Shared helpers ────────────────────────────────────────────────────────
648
+ def _pline(ax, x, y, ci, color, label, ls="-", mk="o"):
649
+ ax.plot(x, y, color=color, label=label, linestyle=ls,
650
+ marker=mk, markersize=5, linewidth=2)
651
+ ax.fill_between(x, y - ci, y + ci, color=color, alpha=0.12)
652
+
653
+ def _cap_ylim(ax, *series, pct=95, margin=1.20):
654
+ vals = [v for s in series for v in (s if hasattr(s, "__iter__") else [s])
655
+ if not np.isnan(v)]
656
+ if vals:
657
+ ax.set_ylim(bottom=0, top=np.nanpercentile(vals, pct) * margin)
658
+
659
+ def _fmt(ax, xlabel, xscale="linear", legend_loc="upper right"):
660
+ ax.set_xlabel(xlabel, fontsize=9)
661
+ ax.set_ylabel("Intervention Distance", fontsize=10)
662
+ ax.set_xscale(xscale)
663
+ ax.grid(True, which="major", linestyle="-", alpha=0.5)
664
+ ax.grid(True, which="minor", linestyle=":", alpha=0.3)
665
+ ax.legend(fontsize=8, framealpha=0.9, loc=legend_loc)
666
+
667
+ # ── Figure A — data efficiency ────────────────────────────────────────────
668
+ D_A = sorted(pc_er["observed"].unique()) if d_panels_a is None else list(d_panels_a)
669
+ fig_a, axes_a = plt.subplots(1, len(D_A), figsize=(4 * len(D_A), 5), sharey=False)
670
+ if len(D_A) == 1:
671
+ axes_a = [axes_a]
672
+
673
+ for ax, d in zip(axes_a, D_A):
674
+ pc_s = pc_er [pc_er ["observed"] == d].sort_values("n_samples")
675
+ bpc_s = bpc_er [(bpc_er ["observed"] == d) & np.isclose(bpc_er ["fraction_required"], frac)].sort_values("n_samples")
676
+ bpcg_s = bpcg_er[(bpcg_er["observed"] == d) & np.isclose(bpcg_er["fraction_required"], frac)].sort_values("n_samples")
677
+ lo_s = lo_er [lo_er ["observed"] == d].drop_duplicates("n_samples").sort_values("n_samples")
678
+ blo_s = lo_er [(lo_er ["observed"] == d) & np.isclose(lo_er ["fraction_required"], frac)].sort_values("n_samples")
679
+ bg_s = bg_er [(bg_er ["observed"] == d) & np.isclose(bg_er ["fraction_required"], frac)].sort_values("n_samples")
680
+
681
+ _pline(ax, pc_s["n_samples"], pc_s["int_dist_mean"], pc_s["ci_int_dist"],
682
+ C["pc"], "PC", "-", "o")
683
+ if not bpc_s.empty:
684
+ _pline(ax, bpc_s["n_samples"], bpc_s["int_dist_mean"], bpc_s["ci_int_dist"],
685
+ C["bpc"], "b-PC (local)", "-", "s")
686
+ if not bpcg_s.empty:
687
+ _pline(ax, bpcg_s["n_samples"], bpcg_s["int_dist_mean"], bpcg_s["ci_int_dist"],
688
+ C["bpc_glob"], "b-PC (global)", "-", "D")
689
+ _pline(ax, lo_s["n_samples"], lo_s["int_dist_without_b_mean"], lo_s["ci_int_dist_without_b"],
690
+ C["load"], "LOAD", "--", "o")
691
+ if not blo_s.empty:
692
+ _pline(ax, blo_s["n_samples"], blo_s["int_dist_with_b_mean"], blo_s["ci_int_dist_with_b"],
693
+ C["bload_local"], "b-LOAD (local)", "--", "s")
694
+ if not bg_s.empty:
695
+ _pline(ax, bg_s["n_samples"], bg_s["int_dist_with_b_mean"], bg_s["ci_int_dist_with_b"],
696
+ C["bload_glob"], "b-LOAD (global)", "--", "^")
697
+
698
+ _cap_ylim(ax, list(pc_s["int_dist_mean"]), list(lo_s["int_dist_without_b_mean"]),
699
+ list(bpc_s["int_dist_mean"]) if not bpc_s.empty else [],
700
+ list(bpcg_s["int_dist_mean"]) if not bpcg_s.empty else [],
701
+ list(blo_s["int_dist_with_b_mean"]) if not blo_s.empty else [],
702
+ list(bg_s["int_dist_with_b_mean"]) if not bg_s.empty else [])
703
+ ax.set_title(f"d = {d} nodes", fontsize=11)
704
+ _fmt(ax, "n_samples (log scale)", xscale="log")
705
+
706
+ plt.tight_layout()
707
+ if save_prefix:
708
+ fig_a.savefig(f"{save_prefix}_A.pdf", bbox_inches="tight")
709
+
710
+ # ── Figure B — BK sensitivity ─────────────────────────────────────────────
711
+ N_B = [n for n in n_panels_bc if n in all_n]
712
+ fig_b, axes_b = plt.subplots(1, len(N_B), figsize=(5 * len(N_B), 5), sharey=False)
713
+ if len(N_B) == 1:
714
+ axes_b = [axes_b]
715
+
716
+ for ax, n in zip(axes_b, N_B):
717
+ pc_row = pc_er [(pc_er ["observed"] == d_panel_b) & (pc_er ["n_samples"] == n)]
718
+ bpc_s = bpc_er[(bpc_er["observed"] == d_panel_b) & (bpc_er["n_samples"] == n)].sort_values("fraction_required")
719
+ bpcg_s = bpcg_er[(bpcg_er["observed"] == d_panel_b) & (bpcg_er["n_samples"] == n)].sort_values("fraction_required")
720
+ lo_rows = lo_er [(lo_er ["observed"] == d_panel_b) & (lo_er ["n_samples"] == n)]
721
+ lo_row = lo_rows.drop_duplicates("n_samples")
722
+ blo_s = lo_rows.sort_values("fraction_required")
723
+ bg_s = bg_er [(bg_er ["observed"] == d_panel_b) & (bg_er ["n_samples"] == n)].sort_values("fraction_required")
724
+
725
+ if not pc_row.empty:
726
+ py, pci = pc_row["int_dist_mean"].iloc[0], pc_row["ci_int_dist"].iloc[0]
727
+ ax.axhline(py, color=C["pc"], lw=2, ls="-", label="PC")
728
+ ax.axhspan(py - pci, py + pci, color=C["pc"], alpha=0.10)
729
+ if not lo_row.empty:
730
+ ly, lci = lo_row["int_dist_without_b_mean"].iloc[0], lo_row["ci_int_dist_without_b"].iloc[0]
731
+ ax.axhline(ly, color=C["load"], lw=2, ls="--", label="LOAD")
732
+ ax.axhspan(ly - lci, ly + lci, color=C["load"], alpha=0.10)
733
+
734
+ if not bpc_s.empty:
735
+ _pline(ax, bpc_s["fraction_required"], bpc_s["int_dist_mean"], bpc_s["ci_int_dist"],
736
+ C["bpc"], "b-PC (local)", "-", "s")
737
+ if not bpcg_s.empty:
738
+ _pline(ax, bpcg_s["fraction_required"], bpcg_s["int_dist_mean"], bpcg_s["ci_int_dist"],
739
+ C["bpc_glob"], "b-PC (global)", "-", "D")
740
+ if not blo_s.empty:
741
+ _pline(ax, blo_s["fraction_required"], blo_s["int_dist_with_b_mean"], blo_s["ci_int_dist_with_b"],
742
+ C["bload_local"], "b-LOAD (local)", "--", "s")
743
+ if not bg_s.empty:
744
+ _pline(ax, bg_s["fraction_required"], bg_s["int_dist_with_b_mean"], bg_s["ci_int_dist_with_b"],
745
+ C["bload_glob"], "b-LOAD (global)", "--", "^")
746
+
747
+ _cap_ylim(ax,
748
+ list(pc_row["int_dist_mean"]) if not pc_row.empty else [],
749
+ list(lo_row["int_dist_without_b_mean"]) if not lo_row.empty else [],
750
+ list(bpc_s["int_dist_mean"]) if not bpc_s.empty else [],
751
+ list(bpcg_s["int_dist_mean"]) if not bpcg_s.empty else [],
752
+ list(blo_s["int_dist_with_b_mean"]) if not blo_s.empty else [],
753
+ list(bg_s["int_dist_with_b_mean"]) if not bg_s.empty else [],
754
+ pct=100)
755
+ ax.set_title(f"n = {n} samples", fontsize=11)
756
+ _fmt(ax, "fraction_required")
757
+
758
+ plt.tight_layout()
759
+ if save_prefix:
760
+ fig_b.savefig(f"{save_prefix}_B.pdf", bbox_inches="tight")
761
+
762
+ # ── Figure C — scalability ────────────────────────────────────────────────
763
+ N_C = [n for n in n_panels_bc if n in all_n]
764
+ fig_c, axes_c = plt.subplots(1, len(N_C), figsize=(5 * len(N_C), 5), sharey=False)
765
+ if len(N_C) == 1:
766
+ axes_c = [axes_c]
767
+
768
+ for ax, n in zip(axes_c, N_C):
769
+ pc_s = pc_er [pc_er ["n_samples"] == n].sort_values("observed")
770
+ bpc_s = bpc_er [(bpc_er ["n_samples"] == n) & np.isclose(bpc_er ["fraction_required"], frac)].sort_values("observed")
771
+ bpcg_s = bpcg_er[(bpcg_er["n_samples"] == n) & np.isclose(bpcg_er["fraction_required"], frac)].sort_values("observed")
772
+ lo_s = lo_er [lo_er ["n_samples"] == n].drop_duplicates("observed").sort_values("observed")
773
+ blo_s = lo_er [(lo_er ["n_samples"] == n) & np.isclose(lo_er ["fraction_required"], frac)].sort_values("observed")
774
+ bg_s = bg_er [(bg_er ["n_samples"] == n) & np.isclose(bg_er ["fraction_required"], frac)].sort_values("observed")
775
+
776
+ _pline(ax, pc_s["observed"], pc_s["int_dist_mean"], pc_s["ci_int_dist"],
777
+ C["pc"], "PC", "-", "o")
778
+ if not bpc_s.empty:
779
+ _pline(ax, bpc_s["observed"], bpc_s["int_dist_mean"], bpc_s["ci_int_dist"],
780
+ C["bpc"], "b-PC (local)", "-", "s")
781
+ if not bpcg_s.empty:
782
+ _pline(ax, bpcg_s["observed"], bpcg_s["int_dist_mean"], bpcg_s["ci_int_dist"],
783
+ C["bpc_glob"], "b-PC (global)", "-", "D")
784
+ _pline(ax, lo_s["observed"], lo_s["int_dist_without_b_mean"], lo_s["ci_int_dist_without_b"],
785
+ C["load"], "LOAD", "--", "o")
786
+ if not blo_s.empty:
787
+ _pline(ax, blo_s["observed"], blo_s["int_dist_with_b_mean"], blo_s["ci_int_dist_with_b"],
788
+ C["bload_local"], "b-LOAD (local)", "--", "s")
789
+ if not bg_s.empty:
790
+ _pline(ax, bg_s["observed"], bg_s["int_dist_with_b_mean"], bg_s["ci_int_dist_with_b"],
791
+ C["bload_glob"], "b-LOAD (global)", "--", "^")
792
+
793
+ _cap_ylim(ax, list(pc_s["int_dist_mean"]), list(lo_s["int_dist_without_b_mean"]),
794
+ list(bpc_s["int_dist_mean"]) if not bpc_s.empty else [],
795
+ list(bpcg_s["int_dist_mean"]) if not bpcg_s.empty else [],
796
+ list(blo_s["int_dist_with_b_mean"]) if not blo_s.empty else [],
797
+ list(bg_s["int_dist_with_b_mean"]) if not bg_s.empty else [])
798
+ ax.set_title(f"n = {n} samples", fontsize=11)
799
+ _fmt(ax, "observed (number of nodes)", legend_loc="upper left")
800
+
801
+ plt.tight_layout()
802
+ if save_prefix:
803
+ fig_c.savefig(f"{save_prefix}_C.pdf", bbox_inches="tight")
804
+
805
+ return fig_a, fig_b, fig_c