Skip to content

Commit 2f24f02

Browse files
committed
feat: add rigorous baseline evaluation script (Softmax, MC-Dropout, Ensemble)
1 parent 2ccb78e commit 2f24f02

1 file changed

Lines changed: 202 additions & 0 deletions

File tree

evaluate_baselines.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import sys
2+
import traceback
3+
4+
try:
5+
import os
6+
7+
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
8+
import numpy as np
9+
import scipy.stats as stats
10+
import tensorflow as tf
11+
12+
from cnsd import Dataset
13+
from cnsd.diagnosis.system import CNSD
14+
from cnsd.perception.cnn import _train_cnn # <--- WE IMPORT JUST THE CNN TRAINER
15+
from cnsd.physics import PhysicsConfig
16+
from validate_pu import load_pu_domain_split
17+
18+
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()
20+
21+
unique_rpm = set(cond_train).union(set(cond_target))
22+
rpm_map = {float(r): float(r) for r in unique_rpm}
23+
pu_physics = PhysicsConfig(
24+
bearing={'n_balls': 8, 'd_ball': 6.75, 'd_pitch': 28.5, 'contact_angle': 0.0},
25+
cond_to_rpm=rpm_map,
26+
fs=64000,
27+
name='PU-6203',
28+
)
29+
pu_taxonomy = {
30+
0: ('Normal', 'None'),
31+
1: ('Outer Race', 'Medium'),
32+
2: ('Inner Race', 'High'),
33+
}
34+
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)
125+
126+
class AlwaysDropout(tf.keras.layers.Dropout):
127+
def call(self, inputs, training=None):
128+
return super().call(inputs, training=True)
129+
130+
def clone_for_mc(layer):
131+
if isinstance(layer, tf.keras.layers.Dropout):
132+
return AlwaysDropout(layer.rate)
133+
return layer.__class__.from_config(layer.get_config())
134+
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}'
171+
)
172+
print('-' * 60)
173+
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')
193+
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+
198+
except Exception:
199+
with open('crash_traceback.txt', 'w') as f:
200+
traceback.print_exc(file=f)
201+
print('CRASHED. Check crash_traceback.txt')
202+
sys.exit(1)

0 commit comments

Comments
 (0)