Skip to content

Commit edae1bc

Browse files
committed
Update baseline evaluation script: add multi-seed, 80/20 train/calib split, freeze tau, and compute exact matched coverage
1 parent 2f24f02 commit edae1bc

1 file changed

Lines changed: 204 additions & 156 deletions

File tree

evaluate_baselines.py

Lines changed: 204 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,40 @@
11
import sys
22
import traceback
3+
import os
4+
import numpy as np
5+
import scipy.stats as stats
36

47
try:
8+
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
59
import os
10+
import site
11+
import sys
12+
13+
# Inject pip-installed NVIDIA DLLs into Windows PATH so TF 2.10 can use the GPU without a system CUDA install
14+
try:
15+
site_packages = site.getsitepackages()[1] if len(site.getsitepackages()) > 1 else site.getsitepackages()[0]
16+
nvidia_base = os.path.join(site_packages, "nvidia")
17+
dll_paths = [
18+
os.path.join(nvidia_base, "cudnn", "bin"),
19+
os.path.join(nvidia_base, "cublas", "bin"),
20+
os.path.join(nvidia_base, "cuda_runtime", "bin"),
21+
]
22+
os.environ["PATH"] = os.pathsep.join(dll_paths) + os.pathsep + os.environ.get("PATH", "")
23+
except Exception:
24+
pass
625

7-
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
8-
import numpy as np
9-
import scipy.stats as stats
1026
import tensorflow as tf
1127

1228
from cnsd import Dataset
1329
from cnsd.diagnosis.system import CNSD
14-
from cnsd.perception.cnn import _train_cnn # <--- WE IMPORT JUST THE CNN TRAINER
30+
from cnsd.perception.cnn import _train_cnn
1531
from cnsd.physics import PhysicsConfig
1632
from validate_pu import load_pu_domain_split
1733

1834
print('Loading Authentic PU dataset (Cross-Domain RPM Split)...')
19-
(X_train, y_train, cond_train), (X_target, y_target, cond_target) = load_pu_domain_split()
35+
(X_train_full, y_train_full, cond_train_full), (X_target, y_target, cond_target) = load_pu_domain_split()
2036

21-
unique_rpm = set(cond_train).union(set(cond_target))
37+
unique_rpm = set(cond_train_full).union(set(cond_target))
2238
rpm_map = {float(r): float(r) for r in unique_rpm}
2339
pu_physics = PhysicsConfig(
2440
bearing={'n_balls': 8, 'd_ball': 6.75, 'd_pitch': 28.5, 'contact_angle': 0.0},
@@ -32,96 +48,22 @@
3248
2: ('Inner Race', 'High'),
3349
}
3450

35-
# Shuffle train data exactly like validate_pu.py to ensure parity
36-
train_indices = np.arange(len(y_train))
37-
np.random.seed(42)
38-
np.random.shuffle(train_indices)
39-
X_train, y_train, cond_train = (
40-
X_train[train_indices],
41-
y_train[train_indices],
42-
cond_train[train_indices],
43-
)
44-
45-
train_ds = Dataset.from_arrays(
46-
X_train,
47-
y_train,
48-
cond_train,
49-
fs=64000,
50-
physics=pu_physics,
51-
taxonomy=pu_taxonomy,
52-
name='PU_Train',
53-
)
54-
test_ds = Dataset.from_arrays(
55-
X_target,
56-
y_target,
57-
cond_target,
58-
fs=64000,
59-
physics=pu_physics,
60-
taxonomy=pu_taxonomy,
61-
name='PU_Test',
62-
)
63-
64-
# Train Primary Model
65-
print('\n[A] Training Primary CNSD Model (Seed=42)...')
66-
np.random.seed(42)
67-
tf.random.set_seed(42)
68-
model = CNSD()
69-
model.fit(train_ds, epochs=20)
70-
71-
# Train Ensemble Models
72-
# FIX: We only need the CNN part for the ensemble, not the full Physics+DoWhy SCM pipeline!
73-
# Training the full CNSD() 4 times was causing the joblib multiprocessing memory leak.
74-
ens = []
75-
nc = int(train_ds.y.max()) + 1
76-
for s in [100, 101, 102]:
77-
print(f'\n[B] Training Ensemble Member CNN only (Seed={s})...')
78-
np.random.seed(s)
79-
tf.random.set_seed(s)
80-
# Train just the Keras CNN directly, skipping the heavy memory SCM
81-
m_cnn = _train_cnn(train_ds.X, train_ds.y, num_classes=nc, epochs=20, seed=s)
82-
ens.append(m_cnn)
83-
84-
# ---- shared: test signals, labels, condition ----
85-
sig = np.stack([test_ds.X[i].reshape(-1) for i in range(len(test_ds.X))]).astype(np.float32)
86-
yte = test_ds.y
87-
cond_te = test_ds.cond
88-
Xin = sig[..., None] # (n, L, 1) for the conv nets
89-
90-
def gap_by_score(score, correct, thr):
91-
hi = score >= thr
92-
lo = ~hi
93-
ah = correct[hi].mean() if hi.any() else float('nan')
94-
al = correct[lo].mean() if lo.any() else float('nan')
95-
return ah - al, ah, al, int(hi.sum()), int(lo.sum())
96-
97-
print('=' * 60, '\n1. PHYSICS gap across tau\n', '=' * 60)
98-
probs = model.cnn.predict(Xin, batch_size=128, verbose=0)
99-
pred = probs.argmax(1)
100-
correct = pred == yte
101-
print(f'baseline CNN acc on test: {correct.mean():.3f}')
102-
for tau in [1.0, 2.0, 3.0]:
103-
model.symbolic.tau = float(tau)
104-
verds = np.array(
105-
[
106-
model.symbolic.diagnose(sig[i], pred[i], cond_te[i])['verdict']
107-
for i in range(len(sig))
108-
]
109-
)
110-
conf = verds == 'CONFIRMED'
111-
cnfl = verds == 'CONFLICT'
112-
ca = correct[conf].mean() if conf.any() else float('nan')
113-
fa = correct[cnfl].mean() if cnfl.any() else float('nan')
114-
print(
115-
f' tau={tau:.1f} GAP={ca - fa:+.3f} CONF={ca:.3f}(n={conf.sum()}) CNFL={fa:.3f}(n={cnfl.sum()})'
116-
)
117-
118-
print('\n', '=' * 60, '\n2. SOFTMAX confidence\n', '=' * 60)
119-
softmax_conf = probs.max(1)
120-
for thr in [0.5, 0.7, 0.9, 0.95, 0.99]:
121-
g, ah, al, nh, nl = gap_by_score(softmax_conf, correct, thr)
122-
print(f' conf>={thr:.2f} GAP={g:+.3f} HI={ah:.3f}(n={nh}) LO={al:.3f}(n={nl})')
123-
124-
print('\n', '=' * 60, '\n3. MC-DROPOUT (30 passes)\n', '=' * 60)
51+
def get_matched_coverage_gap(score, correct, target_n):
52+
if target_n == 0:
53+
return float('nan')
54+
# Score is higher for MORE confident
55+
# Sort descending by score
56+
sorted_indices = np.argsort(score)[::-1]
57+
hi_indices = sorted_indices[:target_n]
58+
lo_indices = sorted_indices[target_n:]
59+
60+
hi_mask = np.zeros(len(score), dtype=bool)
61+
hi_mask[hi_indices] = True
62+
lo_mask = ~hi_mask
63+
64+
ah = correct[hi_mask].mean() if hi_mask.any() else float('nan')
65+
al = correct[lo_mask].mean() if lo_mask.any() else float('nan')
66+
return ah - al
12567

12668
class AlwaysDropout(tf.keras.layers.Dropout):
12769
def call(self, inputs, training=None):
@@ -132,69 +74,175 @@ def clone_for_mc(layer):
13274
return AlwaysDropout(layer.rate)
13375
return layer.__class__.from_config(layer.get_config())
13476

135-
mc_model = tf.keras.models.clone_model(model.cnn, clone_function=clone_for_mc)
136-
mc_model.set_weights(model.cnn.get_weights())
137-
138-
T = 30
139-
mc = []
140-
for _ in range(T):
141-
mc.append(mc_model.predict(Xin, batch_size=128, verbose=0))
142-
mc = np.stack(mc) # (T,n,classes)
143-
mc_mean = mc.mean(0)
144-
mc_pred = mc_mean.argmax(1)
145-
mc_correct = mc_pred == yte
146-
eps = 1e-12
147-
certainty = (mc_mean * np.log(mc_mean + eps)).sum(1) # = -entropy; higher = more certain
148-
print(f'MC mean acc: {mc_correct.mean():.3f}')
149-
for q in [50, 30, 20, 10]: # keep top (100-q)% most certain as 'reliable'
150-
thr = np.percentile(certainty, q)
151-
g, ah, al, nh, nl = gap_by_score(certainty, mc_correct, thr)
152-
print(f' top {100 - q}% certain GAP={g:+.3f} HI={ah:.3f}(n={nh}) LO={al:.3f}(n={nl})')
153-
154-
print('\n', '=' * 60, '\n4. ENSEMBLE disagreement\n', '=' * 60)
155-
ens_preds = np.stack([m.predict(Xin, batch_size=128, verbose=0).argmax(1) for m in ens])
156-
vote = stats.mode(ens_preds, axis=0, keepdims=False).mode
157-
unanimous = (ens_preds == vote).all(0)
158-
ens_correct = vote == yte
159-
print(f'ensemble vote acc: {ens_correct.mean():.3f} | unanimous: {unanimous.mean():.1%}')
160-
g, ah, al, nh, nl = gap_by_score(
161-
unanimous.astype(float), ens_correct, 1.0
162-
) # unanimous=reliable
163-
print(f' unanimous=reliable GAP={g:+.3f} HI={ah:.3f}(n={nh}) LO={al:.3f}(n={nl})')
164-
165-
print('\n', '=' * 60, '\n5. NOISE TEST: physics CONFLICT on unanimous-but-wrong\n', '=' * 60)
166-
rng = np.random.RandomState(42)
167-
sig_power = (sig**2).mean()
168-
model.symbolic.tau = 3.0
169-
print(
170-
f'{"noise":>6} | {"ens_acc":>7} | {"unanim-wrong":>12} | {"phys catches":>12} | {"base rate":>9}'
77+
seeds = [42, 43, 44, 45, 46]
78+
79+
results = {
80+
'phys_gap': [],
81+
'soft_gap': [],
82+
'mc_gap': [],
83+
'ens_gap': [],
84+
'noise_catch': {db: [] for db in [np.inf, 20, 10, 5, 0]}
85+
}
86+
87+
# Test data processing
88+
test_ds = Dataset.from_arrays(
89+
X_target, y_target, cond_target, fs=64000, physics=pu_physics, taxonomy=pu_taxonomy, name='PU_Test'
17190
)
172-
print('-' * 60)
91+
sig_te = np.stack([test_ds.X[i].reshape(-1) for i in range(len(test_ds.X))]).astype(np.float32)
92+
yte = test_ds.y
93+
cond_te = test_ds.cond
94+
Xin_te = sig_te[..., None]
95+
96+
for seed in seeds:
97+
print(f"\n{'='*80}\n=== RUNNING SEED {seed} ===\n{'='*80}")
98+
tf.keras.backend.clear_session()
99+
np.random.seed(seed)
100+
tf.random.set_seed(seed)
101+
102+
# 1. 80/20 Split for Calibration
103+
indices = np.arange(len(y_train_full))
104+
np.random.shuffle(indices)
105+
106+
split_idx = int(0.8 * len(indices))
107+
train_idx = indices[:split_idx]
108+
calib_idx = indices[split_idx:]
109+
110+
X_tr, y_tr, cond_tr = X_train_full[train_idx], y_train_full[train_idx], cond_train_full[train_idx]
111+
X_ca, y_ca, cond_ca = X_train_full[calib_idx], y_train_full[calib_idx], cond_train_full[calib_idx]
112+
113+
train_ds = Dataset.from_arrays(X_tr, y_tr, cond_tr, fs=64000, physics=pu_physics, taxonomy=pu_taxonomy, name='PU_Train')
114+
calib_ds = Dataset.from_arrays(X_ca, y_ca, cond_ca, fs=64000, physics=pu_physics, taxonomy=pu_taxonomy, name='PU_Calib')
115+
116+
# 2. Train Primary Model (Bypass SCM to prevent multiprocess deadlocks)
117+
model = CNSD()
118+
nc = int(train_ds.y.max()) + 1
119+
model.cnn = _train_cnn(train_ds.X, train_ds.y, num_classes=nc, epochs=20, seed=seed)
120+
model.symbolic = model._build_symbolic(train_ds)
121+
model._fitted = True
122+
123+
# 3. Train Ensemble Models
124+
ens = []
125+
nc = int(train_ds.y.max()) + 1
126+
for s in [seed*10, seed*10+1, seed*10+2]:
127+
np.random.seed(s)
128+
tf.random.set_seed(s)
129+
m_cnn = _train_cnn(train_ds.X, train_ds.y, num_classes=nc, epochs=20, seed=s)
130+
ens.append(m_cnn)
131+
132+
# 4. Calibrate Tau
133+
sig_ca = np.stack([calib_ds.X[i].reshape(-1) for i in range(len(calib_ds.X))]).astype(np.float32)
134+
Xin_ca = sig_ca[..., None]
135+
probs_ca = model.cnn.predict(Xin_ca, batch_size=128, verbose=0)
136+
pred_ca = probs_ca.argmax(1)
137+
correct_ca = (pred_ca == calib_ds.y)
138+
139+
best_tau = 1.0
140+
best_gap = -100.0
141+
for tau in [1.0, 1.5, 2.0, 2.5, 3.0]:
142+
model.symbolic.tau = float(tau)
143+
verds = np.array([model.symbolic.diagnose(sig_ca[i], pred_ca[i], calib_ds.cond[i])['verdict'] for i in range(len(sig_ca))])
144+
conf = (verds == 'CONFIRMED')
145+
cnfl = (verds == 'CONFLICT')
146+
ca = correct_ca[conf].mean() if conf.any() else 0.0
147+
fa = correct_ca[cnfl].mean() if cnfl.any() else 1.0
148+
gap = ca - fa
149+
if gap > best_gap:
150+
best_gap = gap
151+
best_tau = tau
152+
153+
print(f"Calibrated best tau = {best_tau} (Calib GAP = {best_gap:+.3f})")
154+
model.symbolic.tau = float(best_tau)
155+
156+
# 5. Evaluate on Test Set
157+
probs_te = model.cnn.predict(Xin_te, batch_size=128, verbose=0)
158+
pred_te = probs_te.argmax(1)
159+
correct_te = (pred_te == yte)
160+
161+
# Physics evaluation
162+
verds = np.array([model.symbolic.diagnose(sig_te[i], pred_te[i], cond_te[i])['verdict'] for i in range(len(sig_te))])
163+
conf = (verds == 'CONFIRMED')
164+
cnfl = (verds == 'CONFLICT')
165+
ca = correct_te[conf].mean() if conf.any() else float('nan')
166+
fa = correct_te[cnfl].mean() if cnfl.any() else float('nan')
167+
phys_gap = ca - fa
168+
target_n = int(conf.sum())
169+
results['phys_gap'].append(phys_gap)
170+
print(f"Physics GAP={phys_gap:+.3f} (Coverage N={target_n})")
171+
172+
# Softmax evaluation at matched coverage
173+
softmax_score = probs_te.max(1)
174+
soft_gap = get_matched_coverage_gap(softmax_score, correct_te, target_n)
175+
results['soft_gap'].append(soft_gap)
176+
177+
# MC-Dropout at matched coverage
178+
mc_model = tf.keras.models.clone_model(model.cnn, clone_function=clone_for_mc)
179+
mc_model.set_weights(model.cnn.get_weights())
180+
T = 30
181+
mc_preds = []
182+
for _ in range(T):
183+
mc_preds.append(mc_model.predict(Xin_te, batch_size=128, verbose=0))
184+
mc_preds = np.stack(mc_preds)
185+
mc_mean = mc_preds.mean(0)
186+
mc_pred_class = mc_mean.argmax(1)
187+
mc_correct = (mc_pred_class == yte)
188+
eps = 1e-12
189+
mc_score = (mc_mean * np.log(mc_mean + eps)).sum(1) # Certainty (negative entropy)
190+
mc_gap = get_matched_coverage_gap(mc_score, mc_correct, target_n)
191+
results['mc_gap'].append(mc_gap)
192+
193+
# Ensemble at matched coverage
194+
ens_preds_probs = np.stack([m.predict(Xin_te, batch_size=128, verbose=0) for m in ens]) # (3, n, c)
195+
ens_mean = ens_preds_probs.mean(0) # (n, c)
196+
ens_pred_class = ens_mean.argmax(1)
197+
ens_correct = (ens_pred_class == yte)
198+
# Score = negative entropy of ensemble mean
199+
ens_score = (ens_mean * np.log(ens_mean + eps)).sum(1)
200+
ens_gap = get_matched_coverage_gap(ens_score, ens_correct, target_n)
201+
results['ens_gap'].append(ens_gap)
202+
203+
print(f"Matched Coverage GAPs -> Softmax:{soft_gap:+.3f} | MC-Drop:{mc_gap:+.3f} | Ens:{ens_gap:+.3f}")
204+
205+
# 6. Noise Test
206+
rng = np.random.RandomState(seed)
207+
sig_power = (sig_te ** 2).mean()
208+
for snr_db in [np.inf, 20, 10, 5, 0]:
209+
if np.isinf(snr_db):
210+
sig_n = sig_te
211+
else:
212+
npow = sig_power / (10 ** (snr_db / 10))
213+
sig_n = sig_te + rng.randn(*sig_te.shape).astype(np.float32) * np.sqrt(npow)
214+
215+
Xin_n = sig_n[..., None]
216+
# Use ensemble mode vote to define 'unanimous' exactly like Abhi's template
217+
ep_class = np.stack([m.predict(Xin_n, batch_size=128, verbose=0).argmax(1) for m in ens])
218+
v = stats.mode(ep_class, axis=0, keepdims=False).mode
219+
unan = (ep_class == v).all(0)
220+
ok = (v == yte)
221+
222+
pv = np.array([model.symbolic.diagnose(sig_n[i], v[i], cond_te[i])['verdict'] for i in range(len(sig_n))])
223+
pc = (pv == 'CONFLICT')
224+
uw = unan & (~ok)
225+
catch = pc[uw].mean() if uw.sum() > 0 else float('nan')
226+
results['noise_catch'][snr_db].append(catch)
227+
s = 'clean' if np.isinf(snr_db) else f'{snr_db}dB'
228+
print(f" Noise={s:>5} | catch_rate={catch:.3f}")
229+
230+
print("\n" + "="*60 + "\nFINAL AGGREGATED RESULTS (5 Seeds)\n" + "="*60)
231+
print(f"Physics GAP: {np.nanmean(results['phys_gap']):+.3f} ± {np.nanstd(results['phys_gap']):.3f}")
232+
print(f"Softmax GAP: {np.nanmean(results['soft_gap']):+.3f} ± {np.nanstd(results['soft_gap']):.3f}")
233+
print(f"MC-Drop GAP: {np.nanmean(results['mc_gap']):+.3f} ± {np.nanstd(results['mc_gap']):.3f}")
234+
print(f"Ensemble GAP: {np.nanmean(results['ens_gap']):+.3f} ± {np.nanstd(results['ens_gap']):.3f}")
235+
236+
print("\nNoise Test Catch Rate (Physics catches Ensemble's confident errors):")
173237
for snr_db in [np.inf, 20, 10, 5, 0]:
174-
if np.isinf(snr_db):
175-
sig_n = sig
176-
else:
177-
npow = sig_power / (10 ** (snr_db / 10))
178-
sig_n = sig + rng.randn(*sig.shape).astype(np.float32) * np.sqrt(npow)
179-
Xin_n = sig_n[..., None]
180-
ep = np.stack([m.predict(Xin_n, batch_size=128, verbose=0).argmax(1) for m in ens])
181-
v = stats.mode(ep, axis=0, keepdims=False).mode
182-
unan = (ep == v).all(0)
183-
ok = v == yte
184-
pv = np.array(
185-
[
186-
model.symbolic.diagnose(sig_n[i], v[i], cond_te[i])['verdict']
187-
for i in range(len(sig_n))
188-
]
189-
)
190-
pc = pv == 'CONFLICT'
191-
uw = unan & (~ok)
192-
catch = pc[uw].mean() if uw.sum() > 0 else float('nan')
193238
s = 'clean' if np.isinf(snr_db) else f'{snr_db}dB'
194-
print(f'{s:>6} | {ok.mean():>7.3f} | {uw.sum():>12} | {catch:>12.3f} | {pc.mean():>9.3f}')
195-
196-
print('================ DONE ================')
197-
239+
vals = [v for v in results['noise_catch'][snr_db] if not np.isnan(v)]
240+
m = np.nanmean(vals) if len(vals) > 0 else float('nan')
241+
std = np.nanstd(vals) if len(vals) > 0 else float('nan')
242+
print(f" {s:>6}: {m:.3f} ± {std:.3f}")
243+
244+
print("================ DONE ================")
245+
198246
except Exception:
199247
with open('crash_traceback.txt', 'w') as f:
200248
traceback.print_exc(file=f)

0 commit comments

Comments
 (0)