|
| 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