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.
- b_load-0.1.2.dist-info/METADATA +266 -0
- b_load-0.1.2.dist-info/RECORD +33 -0
- b_load-0.1.2.dist-info/WHEEL +5 -0
- b_load-0.1.2.dist-info/licenses/LICENSE +21 -0
- b_load-0.1.2.dist-info/top_level.txt +2 -0
- bload/__init__.py +10 -0
- bload/analysis.py +1 -0
- bload/background_knowledge.py +1 -0
- bload/counting_ci_tests.py +1 -0
- bload/data_generation.py +1 -0
- bload/evaluation.py +1 -0
- bload/load.py +1 -0
- bload/mb_by_mb.py +1 -0
- bload/pc.py +1 -0
- bload/real_data_applications/__init__.py +1 -0
- bload/real_data_applications/dream.py +1 -0
- bload/real_data_applications/sachs.py +1 -0
- bload/running.py +1 -0
- bload/utils.py +1 -0
- src/__init__.py +5 -0
- src/analysis.py +805 -0
- src/background_knowledge.py +283 -0
- src/counting_ci_tests.py +101 -0
- src/data_generation.py +185 -0
- src/evaluation.py +642 -0
- src/load.py +553 -0
- src/mb_by_mb.py +930 -0
- src/pc.py +341 -0
- src/real_data_applications/__init__.py +1 -0
- src/real_data_applications/dream.py +306 -0
- src/real_data_applications/sachs.py +202 -0
- src/running.py +1194 -0
- src/utils.py +106 -0
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
|