catninja123 commited on
Commit
e526ce2
·
verified ·
1 Parent(s): aa66af1

Upload train_v38_2_resid.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. train_v38_2_resid.py +516 -192
train_v38_2_resid.py CHANGED
@@ -1,13 +1,16 @@
1
  """
2
- V38.2 RESIDUALIZED MODEL - Simpson's Paradox Fix
3
  ====================================================================
4
- Key changes from bare model:
5
- 1. Compute school_base_rate (time-respecting within each CV fold)
6
- 2. Residualize all student-level features (subtract school-level mean)
7
- 3. Add explicit student × school interaction features
8
- 4. Keep original features too (let model choose)
9
-
10
- Architecture: CB + LGB + XGBoost 3-model blend, 3-seed x 10-fold GroupKFold (fast mode)
 
 
 
 
11
  """
12
  import pandas as pd
13
  import numpy as np
@@ -16,7 +19,6 @@ warnings.filterwarnings('ignore')
16
  from sklearn.model_selection import GroupKFold
17
  from sklearn.metrics import roc_auc_score, log_loss, brier_score_loss
18
  from sklearn.preprocessing import LabelEncoder
19
- from sklearn.calibration import calibration_curve
20
  from scipy.stats import rankdata
21
 
22
  try:
@@ -39,26 +41,44 @@ OUTPUT_DIR = os.path.join(BASE_DIR, 'output')
39
  os.makedirs(OUTPUT_DIR, exist_ok=True)
40
 
41
  TARGET = 'target'
42
- SEEDS = [42, 123, 456]
43
  N_FOLDS = 10
 
44
  start_time = time.time()
45
 
46
- def safe_num(v, default=-1):
47
- if isinstance(v, (int, float)): return float(v)
 
 
 
48
  if isinstance(v, str):
49
- try: return float(v)
50
- except: return default
 
 
 
51
  return default
52
 
53
  # ============================================================
54
- # 1. LOAD DATA
55
  # ============================================================
56
  print("=" * 70)
57
- print(" V38.2 RESIDUALIZED MODEL: SIMPSON'S PARADOX FIX")
58
  print("=" * 70)
59
 
60
- df_raw = pd.read_csv(os.path.join(DATA_DIR, 'v38_2_integrated_features.csv'))
61
- print(f"Integrated features loaded: {df_raw.shape}")
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  # Load LLM features
64
  llm_features_loaded = {}
@@ -76,16 +96,137 @@ for fname, varname in [
76
  else:
77
  llm_features_loaded[varname] = {}
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  # ============================================================
80
- # 2. DATA PREPARATION
81
  # ============================================================
82
- df = df_raw[~df_raw['year'].isin([2018, 2019])].copy().reset_index(drop=True)
83
- print(f"\nAfter removing 2018-2019: {df.shape}")
84
- print(f"Years: {sorted(df['year'].unique())}")
85
- print(f"Admit rate: {df[TARGET].mean():.3f}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  # ============================================================
88
- # 3. PARSE LLM FEATURES (same as bare model)
89
  # ============================================================
90
  act_scores = {}
91
  raw = llm_features_loaded.get('act_scores', {})
@@ -112,11 +253,19 @@ if isinstance(raw, list):
112
  sid = str(item.get('student_id', '')).replace('.0', '')
113
  school = str(item.get('school', ''))
114
  key = f"{sid}_{school}"
 
 
 
 
115
  supp_scores[key] = item
116
  elif isinstance(raw, dict):
117
  for key, scores in raw.items():
118
  if isinstance(scores, dict):
 
 
 
119
  supp_scores[key] = scores
 
120
 
121
  major_diff = llm_features_loaded.get('major_diff', {})
122
  if isinstance(major_diff, list):
@@ -153,58 +302,62 @@ if not PS_DIMS:
153
  'coherence_focus', 'overall_effectiveness']
154
 
155
  # ============================================================
156
- # 4. DEFINE STUDENT-LEVEL FEATURE COLUMNS (for residualization)
157
  # ============================================================
158
- # These are features that are constant across all applications of the same student
159
  STUDENT_LEVEL_NUMERIC = [
160
- # Test scores
161
  'toefl', 'sat', 'gpa',
162
- # Activity type counts
163
- *[f'act_type_count_{t}' for t in [
164
- 'Academic', 'Art', 'Athletics_Club', 'Athletics_JV', 'Athletics_Varsity',
165
- 'Career_Oriented', 'Community_Service_Volunteer', 'Computer_Technology',
166
- 'Cultural', 'Dance', 'Debate_Speech', 'Environmental', 'Family_Responsibilities',
167
- 'Foreign_Exchange', 'Internship', 'Journalism_Publication',
168
- 'Junior_ROTC', 'LGBT', 'Music_Instrumental', 'Music_Vocal',
169
- 'Religious', 'Research', 'Robotics', 'School_Spirit',
170
- 'Science_Math', 'Social_Justice', 'Student_Govt', 'Theater_Drama', 'Work_Paid'
171
- ]],
172
  'act_total_count', 'act_type_diversity',
173
- # Activity PCA
174
  *[f'act_slot_pca_{i}' for i in range(20)],
175
- # Activity BERT PCA
176
  *[f'act_bert_pca_{i}' for i in range(16)],
177
- # PS BERT PCA
178
  *[f'ps_bert_pca_{i}' for i in range(16)],
179
- # Honors
180
  'honors_max_score', 'honors_avg_score', 'honors_min_score',
181
  'honors_count', 'honors_total_score',
182
- 'honors_has_top_tier', 'honors_tier1_count', 'honors_tier2_count', 'honors_tier3_count',
183
- # Summer programs
184
- 'summer_max_geili', 'summer_avg_geili', 'summer_total_geili',
185
- 'summer_count', 'summer_has_top_tier', 'summer_top_tier_count',
186
- 'summer_max_difficulty', 'summer_avg_difficulty',
187
- 'summer_max_recommendation', 'summer_avg_recommendation',
188
- # Cuilu (hs-level, constant per student)
189
- 'cuilu_hs_top10_rate', 'cuilu_hs_top20_rate', 'cuilu_hs_top30_rate',
190
- 'cuilu_hs_total_admits', 'cuilu_hs_total_apps',
191
  'cuilu_feeder_rank', 'cuilu_hs_type_rate', 'cuilu_region_rate',
 
 
 
 
 
 
192
  ]
193
 
194
- # Key student features for explicit interactions with school
 
 
 
 
 
 
 
195
  KEY_STUDENT_FEATURES = [
196
  'toefl', 'sat', 'gpa',
197
  'honors_max_score', 'honors_avg_score', 'honors_count',
198
- 'summer_max_geili', 'summer_count',
199
  'act_type_diversity', 'act_total_count',
 
 
 
 
 
 
 
 
 
200
  ]
201
 
202
  # ============================================================
203
- # 5. BUILD FEATURES (with residualization)
204
  # ============================================================
205
  def build_features_base(df):
206
- """Build base features WITHOUT residualization (same as bare model).
207
- Residualization is done separately inside CV loop to avoid leakage."""
208
  df = df.copy()
209
 
210
  df['is_partial_year'] = (df['year'] == 2025).astype(int)
@@ -215,57 +368,57 @@ def build_features_base(df):
215
  for dim in ACT_DIMS:
216
  col_name = f'llm_act_{dim}'
217
  df[col_name] = df['sid_str'].map(
218
- lambda s, d=dim: safe_num(act_scores.get(s, {}).get(d, -1)))
219
 
220
- # LLM Supp features (student × school level)
221
  def get_supp_score(row, dim):
222
  key = f"{row['sid_str']}_{row['school']}"
223
- return safe_num(supp_scores.get(key, {}).get(dim, -1))
224
  for dim in SUPP_DIMS:
225
  col_name = f'supp_{dim}'
226
  df[col_name] = df.apply(lambda r, d=dim: get_supp_score(r, d), axis=1)
227
 
228
- # Major difficulty (student × school level)
229
  def get_major_diff(row):
230
  key = f"{row['school']}_{row['major_cat']}"
231
- return safe_num(major_diff.get(key, {}).get('difficulty_score', -1))
232
  df['major_difficulty'] = df.apply(get_major_diff, axis=1)
233
 
234
  # PS Yale scores
235
  for dim in PS_DIMS:
236
  col_name = f'ps_{dim}'
237
  df[col_name] = df['sid_str'].map(
238
- lambda s, d=dim: safe_num(ps_yale.get(s, {}).get(d, -1)))
239
 
240
- # Aggregate LLM features
241
  llm_act_cols = [f'llm_act_{d}' for d in ACT_DIMS]
242
- valid_act = df[llm_act_cols].replace(-1, np.nan)
243
  df['llm_act_mean'] = valid_act.mean(axis=1)
244
  df['llm_act_max'] = valid_act.max(axis=1)
245
  df['llm_act_n_valid'] = valid_act.notna().sum(axis=1)
246
 
247
  supp_num_cols = [f'supp_{d}' for d in SUPP_DIMS if d not in ['has_red_flag']]
248
- valid_supp = df[supp_num_cols].replace(-1, np.nan)
249
  df['supp_mean'] = valid_supp.mean(axis=1)
250
  df['supp_max'] = valid_supp.max(axis=1)
251
 
252
  ps_cols = [f'ps_{d}' for d in PS_DIMS]
253
- valid_ps = df[ps_cols].replace(-1, np.nan)
254
  df['ps_mean'] = valid_ps.mean(axis=1)
255
 
256
- # Basic interactions (non-residualized)
257
  df['toefl_x_sat'] = df['toefl'] * df['sat'] / 10000.0
258
  df['gpa_x_toefl'] = df['gpa'] * df['toefl'] / 100.0
259
- df['llm_act_x_supp'] = df['llm_act_mean'].fillna(0) * df['supp_mean'].fillna(0)
260
 
261
  if 'honors_avg_score' in df.columns:
262
- df['honors_x_sat'] = df['honors_avg_score'].fillna(0) * df['sat'].fillna(0) / 1600
263
- df['honors_x_toefl'] = df['honors_avg_score'].fillna(0) * df['toefl'].fillna(0) / 120
264
 
265
  if 'cuilu_hs_top10_rate' in df.columns and 'taste_score_sensitivity' in df.columns:
266
- df['cuilu_x_taste'] = df['cuilu_hs_top10_rate'].fillna(0) * df['taste_score_sensitivity'].fillna(0)
267
 
268
- # Categoricals
269
  cat_cols = ['school', 'round_cat', 'major_cat', 'hs_cat', 'year_cat', 'hs_name', 'province']
270
  cat_cols = [c for c in cat_cols if c in df.columns]
271
 
@@ -286,86 +439,155 @@ def build_features_base(df):
286
  return df, cat_cols
287
 
288
 
289
- def add_residualized_features(df, train_mask, cat_cols):
290
- """Add residualized + interaction features using ONLY training data statistics.
291
- This must be called inside each CV fold to prevent leakage."""
292
  df = df.copy()
293
 
294
- # Step 1: Compute school_base_rate from training data only
295
  train_df = df[train_mask]
 
 
296
  school_stats = train_df.groupby('school').agg(
297
- school_base_rate=(TARGET, 'mean'),
298
  school_n_apps=(TARGET, 'count'),
299
  school_n_admits=(TARGET, 'sum'),
300
  ).reset_index()
301
 
302
- # Merge school stats to all rows
303
- df = df.merge(school_stats, on='school', how='left')
304
- # Fill missing school stats with global mean
305
- global_rate = train_df[TARGET].mean()
 
 
 
 
306
  df['school_base_rate'] = df['school_base_rate'].fillna(global_rate)
307
  df['school_n_apps'] = df['school_n_apps'].fillna(0)
308
  df['school_n_admits'] = df['school_n_admits'].fillna(0)
309
 
310
- # Step 2: Compute school-level means for student features (from training data)
311
- student_feat_available = [c for c in STUDENT_LEVEL_NUMERIC if c in df.columns]
 
312
 
313
- school_means = train_df.groupby('school')[student_feat_available].mean()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
- # Step 3: Residualize student features
316
  resid_cols = []
317
  for col in student_feat_available:
318
  resid_col = f'{col}_resid'
319
- col_school_mean = df['school'].map(school_means[col]).fillna(0)
320
- df[resid_col] = df[col].fillna(0) - col_school_mean
 
 
 
321
  resid_cols.append(resid_col)
322
 
323
- # Step 4: Explicit interactions (student feature × school_base_rate)
324
  interaction_cols = []
325
  for col in KEY_STUDENT_FEATURES:
326
  if col in df.columns:
327
  int_col = f'{col}_x_school_rate'
328
- df[int_col] = df[col].fillna(0) * df['school_base_rate']
329
  interaction_cols.append(int_col)
330
 
331
- # Also: residualized × school_rate
332
  resid_col = f'{col}_resid'
333
  if resid_col in df.columns:
334
  int_resid_col = f'{col}_resid_x_rate'
335
  df[int_resid_col] = df[resid_col] * df['school_base_rate']
336
  interaction_cols.append(int_resid_col)
337
 
338
- # Step 5: Student percentile within school (from training data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  pctile_cols = []
340
- for col in ['toefl', 'sat', 'gpa', 'honors_max_score', 'summer_max_geili']:
 
341
  if col not in df.columns:
342
  continue
343
  pctile_col = f'{col}_school_pctile'
344
- # For each school, compute percentile rank using training data distribution
345
  school_distributions = {}
346
  for school_id in train_df['school'].unique():
347
  vals = train_df[train_df['school'] == school_id][col].dropna().values
348
  if len(vals) > 2:
349
  school_distributions[school_id] = vals
350
 
351
- def compute_pctile(row):
352
  school_id = row['school']
353
  val = row[col]
354
- if pd.isna(val) or school_id not in school_distributions:
355
- return 0.5 # default to median
356
- dist = school_distributions[school_id]
357
  return np.mean(dist <= val)
358
 
359
  df[pctile_col] = df.apply(compute_pctile, axis=1)
360
  pctile_cols.append(pctile_col)
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  # Build final feature list
363
  num_cols = [c for c in df.columns if df[c].dtype in ['float64', 'int64', 'float32', 'int32']
364
  and c not in [TARGET, 'student_id', 'year', 'Unnamed: 0']]
365
 
366
  all_feat = list(set(num_cols + cat_cols))
367
  feature_cols = list(dict.fromkeys([c for c in all_feat if c in df.columns]))
368
- for remove in [TARGET, 'student_id', 'year', 'sid_str', 'Unnamed: 0']:
369
  if remove in feature_cols:
370
  feature_cols.remove(remove)
371
 
@@ -373,18 +595,28 @@ def add_residualized_features(df, train_mask, cat_cols):
373
  to_drop = [c for c in feature_cols if df[c].nunique() <= 1]
374
  feature_cols = [c for c in feature_cols if c not in to_drop]
375
 
376
- # Fill NaN
377
- for c in feature_cols:
378
- if df[c].isnull().any():
379
- df[c] = df[c].fillna(-1)
 
 
 
 
 
 
 
 
 
 
380
  for c in feature_cols:
381
  if df[c].dtype in ['float64', 'float32']:
382
- df[c] = df[c].replace([np.inf, -np.inf], -1)
383
 
384
  cat_indices = [feature_cols.index(c) for c in cat_cols if c in feature_cols]
385
 
386
- new_feat_count = len(resid_cols) + len(interaction_cols) + len(pctile_cols) + 3 # +3 for school stats
387
- print(f" Residualized features added: {len(resid_cols)} resid + {len(interaction_cols)} interactions + {len(pctile_cols)} percentiles + 3 school stats = {new_feat_count} new")
388
 
389
  return df, feature_cols, cat_cols, cat_indices
390
 
@@ -395,14 +627,91 @@ def add_residualized_features(df, train_mask, cat_cols):
395
  df_base, cat_cols = build_features_base(df)
396
  print(f"\nBase features built. Shape: {df_base.shape}")
397
 
 
 
 
 
 
 
398
  y = df_base[TARGET].values
399
  groups = df_base['student_id'].values
400
 
401
  # ============================================================
402
- # 7. TEMPORAL VALIDATION (2020-2023 2024) WITH RESIDUALIZATION
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  # ============================================================
404
  print(f"\n{'='*70}")
405
- print(f" TEMPORAL VALIDATION (2020-2023 2024) WITH RESIDUALIZATION")
406
  print(f"{'='*70}")
407
 
408
  mask_train_temporal = df_base['year'].isin([2020, 2021, 2022, 2023])
@@ -410,66 +719,72 @@ mask_test_temporal = df_base['year'] == 2024
410
 
411
  temporal_results = {}
412
  if mask_test_temporal.sum() > 0:
413
- # Build residualized features using temporal train data
414
  df_temporal, feat_cols_t, cat_cols_t, cat_idx_t = add_residualized_features(
415
- df_base, mask_train_temporal, cat_cols)
416
 
417
  X_t = df_temporal[feat_cols_t].copy()
418
  for c in cat_cols_t:
419
  if c in X_t.columns:
420
  X_t[c] = X_t[c].astype(int)
421
- X_t = X_t.fillna(-1)
422
 
423
  X_tr_t = X_t[mask_train_temporal]
424
  X_te_t = X_t[mask_test_temporal]
425
  y_tr_t = y[mask_train_temporal]
426
  y_te_t = y[mask_test_temporal]
427
 
 
 
 
 
428
  print(f" Train: {len(X_tr_t)}, Test: {len(X_te_t)}, Features: {len(feat_cols_t)}")
429
 
430
  for seed in SEEDS:
 
431
  cb_t = CatBoostClassifier(
432
- iterations=1000, depth=6, learning_rate=0.05,
433
- l2_leaf_reg=5, random_seed=seed, verbose=0,
434
  cat_features=cat_idx_t, eval_metric='AUC',
435
- early_stopping_rounds=80)
436
  pool_tr = Pool(X_tr_t, y_tr_t, cat_features=cat_idx_t)
437
  pool_te = Pool(X_te_t, y_te_t, cat_features=cat_idx_t)
438
  cb_t.fit(pool_tr, eval_set=pool_te, verbose=0)
439
  cb_pred = cb_t.predict_proba(Pool(X_te_t, cat_features=cat_idx_t))[:, 1]
440
  del cb_t; gc.collect()
441
 
442
- lgb_tr = lgb.Dataset(X_tr_t.values, y_tr_t, categorical_feature=cat_idx_t)
443
- lgb_va = lgb.Dataset(X_te_t.values, y_te_t, categorical_feature=cat_idx_t, reference=lgb_tr)
 
444
  lgb_params = {
445
  'objective': 'binary', 'metric': 'auc', 'verbosity': -1,
446
- 'learning_rate': 0.05, 'num_leaves': 63, 'max_depth': 6,
447
- 'min_child_samples': 20, 'reg_alpha': 0.1, 'reg_lambda': 1.0,
448
- 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 5,
449
  'seed': seed
450
  }
451
- lgb_model = lgb.train(lgb_params, lgb_tr, num_boost_round=1000,
452
  valid_sets=[lgb_va],
453
- callbacks=[lgb.early_stopping(80), lgb.log_evaluation(0)])
454
- lgb_pred = lgb_model.predict(X_te_t.values)
455
  del lgb_model; gc.collect()
456
 
457
- dtrain = xgb.DMatrix(X_tr_t.values, label=y_tr_t, enable_categorical=False)
458
- dtest = xgb.DMatrix(X_te_t.values, label=y_te_t, enable_categorical=False)
 
459
  xgb_params = {
460
  'objective': 'binary:logistic', 'eval_metric': 'auc',
461
- 'max_depth': 6, 'learning_rate': 0.05,
462
- 'subsample': 0.8, 'colsample_bytree': 0.8,
463
- 'reg_alpha': 0.1, 'reg_lambda': 1.0,
 
464
  'seed': seed, 'verbosity': 0
465
  }
466
- xgb_model = xgb.train(xgb_params, dtrain, num_boost_round=1000,
467
  evals=[(dtest, 'val')],
468
- early_stopping_rounds=80, verbose_eval=False)
469
  xgb_pred = xgb_model.predict(dtest)
470
  del xgb_model, dtrain, dtest; gc.collect()
471
 
472
- blend = 0.4 * cb_pred + 0.3 * lgb_pred + 0.3 * xgb_pred
473
  temporal_results[seed] = {
474
  'cb': float(roc_auc_score(y_te_t, cb_pred)),
475
  'lgb': float(roc_auc_score(y_te_t, lgb_pred)),
@@ -479,19 +794,19 @@ if mask_test_temporal.sum() > 0:
479
  print(f" Seed {seed}: CB={temporal_results[seed]['cb']:.4f} LGB={temporal_results[seed]['lgb']:.4f} XGB={temporal_results[seed]['xgb']:.4f} Blend={temporal_results[seed]['blend']:.4f}")
480
 
481
  avg_temporal = np.mean([v['blend'] for v in temporal_results.values()])
482
- print(f"\n AVG Temporal Blend: {avg_temporal:.4f} (V37.3: 0.8410, V38.2-bare: 0.8417)")
483
  print(f" Delta vs V37.3: {avg_temporal - 0.8410:+.4f}")
484
- print(f" Delta vs V38.2-bare: {avg_temporal - 0.8417:+.4f}")
485
 
486
  del df_temporal, X_t; gc.collect()
487
  else:
488
  avg_temporal = 0.0
489
 
490
  # ============================================================
491
- # 8. MULTI-SEED GROUPKFOLD WITH RESIDUALIZATION INSIDE FOLDS
492
  # ============================================================
493
  print(f"\n{'='*70}")
494
- print(f" MULTI-SEED GROUPKFOLD WITH RESIDUALIZATION ({len(SEEDS)} seeds x {N_FOLDS} folds)")
495
  print(f"{'='*70}")
496
 
497
  all_cb_oof = []
@@ -508,35 +823,32 @@ for seed_idx, seed in enumerate(SEEDS):
508
  xgb_oof = np.zeros(len(df_base))
509
 
510
  for fold, (tr_idx, va_idx) in enumerate(gkf.split(df_base, y, groups)):
511
- # Create train mask for residualization
512
  train_mask = pd.Series(False, index=df_base.index)
513
  train_mask.iloc[tr_idx] = True
514
 
515
- # Build residualized features using ONLY training fold data
516
  df_fold, feat_cols_f, cat_cols_f, cat_idx_f = add_residualized_features(
517
- df_base, train_mask, cat_cols)
518
 
519
  if feature_cols_final is None:
520
  feature_cols_final = feat_cols_f
521
- print(f" Total features with residualization: {len(feat_cols_f)}")
522
 
523
  X_fold = df_fold[feat_cols_f].copy()
524
  for c in cat_cols_f:
525
  if c in X_fold.columns:
526
  X_fold[c] = X_fold[c].astype(int)
527
- X_fold = X_fold.fillna(-1)
528
 
529
  X_tr_df = X_fold.iloc[tr_idx]
530
  X_va_df = X_fold.iloc[va_idx]
531
  y_tr = y[tr_idx]
532
  y_va = y[va_idx]
533
 
534
- # CatBoost
535
  cb = CatBoostClassifier(
536
- iterations=1000, depth=6, learning_rate=0.05,
537
- l2_leaf_reg=5, random_seed=seed, verbose=0,
538
  cat_features=cat_idx_f, eval_metric='AUC',
539
- early_stopping_rounds=80)
540
  pool_tr = Pool(X_tr_df, y_tr, cat_features=cat_idx_f)
541
  pool_va = Pool(X_va_df, y_va, cat_features=cat_idx_f)
542
  cb.fit(pool_tr, eval_set=pool_va, verbose=0)
@@ -547,37 +859,39 @@ for seed_idx, seed in enumerate(SEEDS):
547
  all_fi.append(cb.get_feature_importance())
548
  del cb, pool_tr, pool_va; gc.collect()
549
 
550
- # LightGBM
551
- X_tr_arr, X_va_arr = X_tr_df.values, X_va_df.values
552
- lgb_tr = lgb.Dataset(X_tr_arr, y_tr, categorical_feature=cat_idx_f)
553
- lgb_va_ds = lgb.Dataset(X_va_arr, y_va, categorical_feature=cat_idx_f, reference=lgb_tr)
 
 
554
  lgb_params = {
555
  'objective': 'binary', 'metric': 'auc', 'verbosity': -1,
556
- 'learning_rate': 0.05, 'num_leaves': 63, 'max_depth': 6,
557
- 'min_child_samples': 20, 'reg_alpha': 0.1, 'reg_lambda': 1.0,
558
- 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 5,
559
  'seed': seed
560
  }
561
- lgb_model = lgb.train(lgb_params, lgb_tr, num_boost_round=1000,
562
  valid_sets=[lgb_va_ds],
563
- callbacks=[lgb.early_stopping(80), lgb.log_evaluation(0)])
564
- lgb_pred = lgb_model.predict(X_va_arr)
565
  lgb_oof[va_idx] = lgb_pred
566
  del lgb_model; gc.collect()
567
 
568
- # XGBoost
569
- dtrain = xgb.DMatrix(X_tr_arr, label=y_tr)
570
- dval = xgb.DMatrix(X_va_arr, label=y_va)
571
  xgb_params = {
572
  'objective': 'binary:logistic', 'eval_metric': 'auc',
573
- 'max_depth': 6, 'learning_rate': 0.05,
574
- 'subsample': 0.8, 'colsample_bytree': 0.8,
575
- 'reg_alpha': 0.1, 'reg_lambda': 1.0,
 
576
  'seed': seed, 'verbosity': 0
577
  }
578
- xgb_model = xgb.train(xgb_params, dtrain, num_boost_round=1000,
579
  evals=[(dval, 'val')],
580
- early_stopping_rounds=80, verbose_eval=False)
581
  xgb_pred = xgb_model.predict(dval)
582
  xgb_oof[va_idx] = xgb_pred
583
  del xgb_model, dtrain, dval, df_fold, X_fold; gc.collect()
@@ -595,7 +909,7 @@ for seed_idx, seed in enumerate(SEEDS):
595
  all_xgb_oof.append(xgb_oof)
596
 
597
  # ============================================================
598
- # 9. ENSEMBLE & BLEND
599
  # ============================================================
600
  print(f"\n{'='*70}")
601
  print(f" ENSEMBLE RESULTS")
@@ -609,14 +923,14 @@ cb_final_auc = roc_auc_score(y, cb_avg)
609
  lgb_final_auc = roc_auc_score(y, lgb_avg)
610
  xgb_final_auc = roc_auc_score(y, xgb_avg)
611
 
612
- print(f" CB 5-seed avg: {cb_final_auc:.4f}")
613
- print(f" LGB 5-seed avg: {lgb_final_auc:.4f}")
614
- print(f" XGB 5-seed avg: {xgb_final_auc:.4f}")
615
 
616
  best_auc = 0
617
- best_weights = (0.4, 0.3, 0.3)
618
  for w_cb in np.arange(0.2, 0.7, 0.05):
619
- for w_lgb in np.arange(0.1, 0.6, 0.05):
620
  w_xgb = 1.0 - w_cb - w_lgb
621
  if w_xgb < 0.05: continue
622
  blend = w_cb * cb_avg + w_lgb * lgb_avg + w_xgb * xgb_avg
@@ -626,26 +940,26 @@ for w_cb in np.arange(0.2, 0.7, 0.05):
626
  best_weights = (w_cb, w_lgb, w_xgb)
627
 
628
  print(f"\n Best 3-model blend: {best_auc:.4f}")
629
- print(f" Delta vs V37.3: {best_auc - 0.8697:+.4f}")
630
- print(f" Delta vs V38.2-bare: {best_auc - 0.8687:+.4f}")
631
  print(f" Weights: CB={best_weights[0]:.2f} LGB={best_weights[1]:.2f} XGB={best_weights[2]:.2f}")
632
 
633
  rank_blend = (rankdata(cb_avg) + rankdata(lgb_avg) + rankdata(xgb_avg)) / 3
634
  rank_auc = roc_auc_score(y, rank_blend)
635
  print(f" Rank blend: {rank_auc:.4f}")
636
 
637
- final_blend = best_weights[0] * cb_avg + best_weights[1] * lgb_avg + best_weights[2] * xgb_avg
638
- final_auc = roc_auc_score(y, final_blend)
639
- final_brier = brier_score_loss(y, final_blend)
640
- final_logloss = log_loss(y, np.clip(final_blend, 1e-7, 1-1e-7))
641
 
642
  print(f"\n FINAL METRICS:")
643
- print(f" AUC: {final_auc:.4f} (V37.3: 0.8697, V38.2-bare: 0.8687)")
644
  print(f" Brier: {final_brier:.4f}")
645
  print(f" LogLoss: {final_logloss:.4f}")
646
 
647
  # ============================================================
648
- # 10. FEATURE IMPORTANCE
649
  # ============================================================
650
  print(f"\n{'='*70}")
651
  print(f" FEATURE IMPORTANCE (avg across seeds)")
@@ -654,36 +968,46 @@ print(f"{'='*70}")
654
  if feature_cols_final and all_fi:
655
  avg_fi = np.mean(all_fi, axis=0)
656
  fi_pairs = sorted(zip(feature_cols_final, avg_fi), key=lambda x: -x[1])
 
657
  print(f" {'Rank':<5s} {'Feature':<50s} {'Importance':>10s}")
658
  print(f" {'-'*5} {'-'*50} {'-'*10}")
659
  for i, (fname, imp) in enumerate(fi_pairs[:50]):
660
  marker = ""
661
- if '_resid' in fname:
662
- marker = " [RESID]"
663
- elif '_x_school_rate' in fname or '_resid_x_rate' in fname:
664
- marker = " [INTERACT]"
665
- elif '_school_pctile' in fname:
666
- marker = " [PCTILE]"
667
- elif fname.startswith('school_base_rate'):
668
- marker = " [SCHOOL_RATE]"
669
  print(f" {i+1:<5d} {fname:<50s} {imp:>10.2f}{marker}")
670
 
671
- # Count new residualized features in top 30
672
- resid_in_top30 = sum(1 for f, _ in fi_pairs[:30] if '_resid' in f or '_x_school_rate' in f or '_school_pctile' in f or 'school_base_rate' in f)
673
  print(f"\n Residualized/interaction features in top 30: {resid_in_top30}")
674
 
675
  # ============================================================
676
- # 11. SAVE RESULTS
677
  # ============================================================
678
  elapsed = time.time() - start_time
679
 
680
  results = {
681
- 'version': 'V38.2-residualized',
682
  'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
683
  'elapsed_minutes': elapsed / 60,
 
 
 
 
 
 
 
 
 
 
 
684
  'comparison': {
685
  'v37_3': {'auc': 0.8697, 'temporal_auc': 0.8410},
686
- 'v38_2_bare': {'auc': 0.8687, 'temporal_auc': 0.8417},
687
  },
688
  'temporal_validation': {
689
  'per_seed': temporal_results,
@@ -703,20 +1027,20 @@ results = {
703
  'feature_importance': [[f, float(i)] for f, i in fi_pairs[:50]] if feature_cols_final and all_fi else [],
704
  }
705
 
706
- with open(os.path.join(OUTPUT_DIR, 'v38_2_resid_results.json'), 'w') as f:
707
  json.dump(results, f, indent=2)
708
 
709
  oof_df = df_base[['student_id', 'school', 'year', TARGET]].copy()
710
  oof_df['cb_pred'] = cb_avg
711
  oof_df['lgb_pred'] = lgb_avg
712
  oof_df['xgb_pred'] = xgb_avg
713
- oof_df['final_pred'] = final_blend
714
- oof_df.to_csv(os.path.join(OUTPUT_DIR, 'v38_2_resid_oof_predictions.csv'), index=False)
715
 
716
  print(f"\n{'='*70}")
717
- print(f" V38.2 RESIDUALIZED MODEL COMPLETE")
718
  print(f" Total time: {elapsed/60:.1f} minutes")
719
  print(f" Features: {len(feature_cols_final) if feature_cols_final else 'N/A'}")
720
- print(f" GroupKFold AUC: {final_auc:.4f} (V37.3: 0.8697, V38.2-bare: 0.8687)")
721
- print(f" Temporal AUC: {avg_temporal:.4f} (V37.3: 0.8410, V38.2-bare: 0.8417)")
722
  print(f"{'='*70}")
 
1
  """
 
2
  ====================================================================
3
+ V38.2-PRO-V4 MODEL - Restored Features + Supp Fix + v6 Feature Matrix
4
+ ====================================================================
5
+ Changes from V38.2-PRO-V3:
6
+ 1. Use v6 feature matrix (v5 + restored v4 columns)
7
+ 2. Restored: hs_to_univ_hist_rate_smoothed (r=0.337!), ps_bert_pca_*, summer features
8
+ 3. Added honors_quality_ratio (max_score / (count+1))
9
+ 4. Supp score=1 (all dims=1) -> NaN (not real scores, just 'no supplement')
10
+ 5. has_ps=0 -> ps_bert_pca all NaN (fix false BERT signals)
11
+ 6. hs_to_univ_hist features: -1 -> NaN (already done in v6 CSV)
12
+ 7. All V3 fixes carried forward (SAT/TOEFL/GPA NaN, CatBoost native NaN)
13
+ ====================================================================
14
  """
15
  import pandas as pd
16
  import numpy as np
 
19
  from sklearn.model_selection import GroupKFold
20
  from sklearn.metrics import roc_auc_score, log_loss, brier_score_loss
21
  from sklearn.preprocessing import LabelEncoder
 
22
  from scipy.stats import rankdata
23
 
24
  try:
 
41
  os.makedirs(OUTPUT_DIR, exist_ok=True)
42
 
43
  TARGET = 'target'
44
+ SEEDS = [42, 123, 456, 789, 2024]
45
  N_FOLDS = 10
46
+ FEATURE_SELECT_TOP_N = 150
47
  start_time = time.time()
48
 
49
+ def safe_num(v, default=np.nan):
50
+ """Convert to float, return NaN for missing (was -1 before)."""
51
+ if isinstance(v, (int, float)):
52
+ val = float(v)
53
+ return np.nan if val == -1 else val
54
  if isinstance(v, str):
55
+ try:
56
+ val = float(v)
57
+ return np.nan if val == -1 else val
58
+ except:
59
+ return default
60
  return default
61
 
62
  # ============================================================
63
+ # 1. LOAD DATA (v6 feature matrix)
64
  # ============================================================
65
  print("=" * 70)
66
+ print(" V38.2-PRO-V4: RESTORED FEATURES + SUPP FIX + V6 MATRIX")
67
  print("=" * 70)
68
 
69
+ # Try v6 first, fall back to v5, then v4
70
+ v6_path = os.path.join(DATA_DIR, 'v38_2_integrated_features_v6.csv')
71
+ v5_path = os.path.join(DATA_DIR, 'v38_2_integrated_features_v5.csv')
72
+ v4_path = os.path.join(DATA_DIR, 'v38_2_integrated_features.csv')
73
+ if os.path.exists(v6_path):
74
+ df_raw = pd.read_csv(v6_path)
75
+ print(f"V6 features loaded: {df_raw.shape}")
76
+ elif os.path.exists(v5_path):
77
+ df_raw = pd.read_csv(v5_path)
78
+ print(f"V5 features loaded (v6 not found): {df_raw.shape}")
79
+ else:
80
+ df_raw = pd.read_csv(v4_path)
81
+ print(f"V4 features loaded: {df_raw.shape}")
82
 
83
  # Load LLM features
84
  llm_features_loaded = {}
 
96
  else:
97
  llm_features_loaded[varname] = {}
98
 
99
+ # Load raw data to get ED2 round info
100
+ import re
101
+ RAW_CSV = os.path.join(DATA_DIR, 'students_with_essays_merged_clean.csv')
102
+ round_lookup = {}
103
+ if os.path.exists(RAW_CSV):
104
+ print(f"\n Loading raw CSV for ED2 round info...")
105
+ try:
106
+ raw_chunks = pd.read_csv(RAW_CSV, usecols=['student_id', 'school_results_summary'],
107
+ dtype=str, chunksize=500)
108
+ for chunk in raw_chunks:
109
+ for _, row in chunk.iterrows():
110
+ sid = str(row.get('student_id', '')).replace('.0', '')
111
+ summary = str(row.get('school_results_summary', ''))
112
+ entries = re.split(r'(?=\d+\.)', summary)
113
+ for entry in entries:
114
+ m = re.search(r'(Early Decision II|Early Decision|Early Action II|Early Action|Restrictive Early Action|Regular Decision)', entry)
115
+ if m:
116
+ round_type = m.group(1)
117
+ school_m = re.search(r'\d+\.\s*(.+?)(?:\s*[-–]\s*|\s*\()', entry)
118
+ if school_m:
119
+ school_name = school_m.group(1).strip()
120
+ key = f"{sid}_{school_name}"
121
+ round_lookup[key] = round_type
122
+ print(f" Round lookup built: {len(round_lookup)} entries")
123
+ except Exception as e:
124
+ print(f" Warning: Could not load raw CSV: {e}")
125
+
126
  # ============================================================
127
+ # 2. DATA CLEANING & QUALITY FIXES
128
  # ============================================================
129
+ print(f"\n{'='*70}")
130
+ print(f" DATA QUALITY FIXES")
131
+ print(f"{'='*70}")
132
+
133
+ # 2a. Filter years
134
+ df = df_raw[~df_raw['year'].isin([2018, 2019])].copy()
135
+ df = df.reset_index(drop=True)
136
+ print(f"After filtering 2018-2019: {df.shape}")
137
+
138
+ # 2b. FIX #1: SAT=0 -> NaN + has_sat
139
+ sat_zero = (df['sat'] == 0).sum()
140
+ df['has_sat'] = (df['sat'] > 0).astype(int)
141
+ df.loc[df['sat'] == 0, 'sat'] = np.nan
142
+ print(f"\n FIX #1: SAT=0 -> NaN: {sat_zero} rows ({sat_zero/len(df)*100:.1f}%)")
143
+ print(f" has_sat=1: {df['has_sat'].sum()}, has_sat=0: {(df['has_sat']==0).sum()}")
144
+
145
+ # 2c. FIX #2: TOEFL=0 -> NaN + has_toefl
146
+ toefl_zero = (df['toefl'] == 0).sum()
147
+ df['has_toefl'] = (df['toefl'] > 0).astype(int)
148
+ df.loc[df['toefl'] == 0, 'toefl'] = np.nan
149
+ print(f" FIX #2: TOEFL=0 -> NaN: {toefl_zero} rows ({toefl_zero/len(df)*100:.1f}%)")
150
+
151
+ # 2d. FIX #3: GPA=0 -> NaN (v5 already has has_gpa)
152
+ gpa_zero = (df['gpa'] == 0).sum()
153
+ df.loc[df['gpa'] == 0, 'gpa'] = np.nan
154
+ print(f" FIX #3: GPA=0 -> NaN: {gpa_zero} rows ({gpa_zero/len(df)*100:.1f}%)")
155
+ if 'has_gpa' not in df.columns:
156
+ df['has_gpa'] = df['gpa'].notna().astype(int)
157
+ print(f" has_gpa=1: {(df['has_gpa']==1).sum()}, has_gpa=0: {(df['has_gpa']==0).sum()}")
158
+
159
+ # 2e. FIX #4: -1 -> NaN for sentinel columns
160
+ sentinel_cols = ['taste_yearly_admits_log']
161
+ # v5 removed hs_to_univ_hist_rate, hs_to_univ_hist_rate_smoothed, hs_overall_hist_rate
162
+ # but check if they exist
163
+ for col in ['hs_to_univ_hist_rate', 'hs_to_univ_hist_rate_smoothed', 'hs_overall_hist_rate']:
164
+ if col in df.columns:
165
+ sentinel_cols.append(col)
166
+
167
+ for col in sentinel_cols:
168
+ if col in df.columns:
169
+ n_neg1 = (df[col] == -1).sum()
170
+ df.loc[df[col] == -1, col] = np.nan
171
+ print(f" FIX #4: {col}: -1 -> NaN: {n_neg1} rows ({n_neg1/len(df)*100:.1f}%)")
172
+
173
+ # 2f. FIX #5: has_ps=0 -> ps_bert all NaN
174
+ act_bert_cols = [c for c in df.columns if c.startswith('act_bert_pca_')]
175
+ # ps_bert_pca columns were removed in v5, but check
176
+ ps_bert_cols = [c for c in df.columns if c.startswith('ps_bert_pca_')]
177
+ if ps_bert_cols:
178
+ no_ps_mask = df['has_ps'] == 0
179
+ n_fix = no_ps_mask.sum()
180
+ for col in ps_bert_cols:
181
+ df.loc[no_ps_mask, col] = np.nan
182
+ print(f" FIX #5: ps_bert -> NaN for has_ps=0: {n_fix} rows, {len(ps_bert_cols)} columns")
183
+ else:
184
+ print(f" FIX #5: No ps_bert_pca columns in v5 (already removed)")
185
+
186
+ # 2g. FIX portfolio_size: log transform + cap (from V2)
187
+ print(f"\n Portfolio size transform:")
188
+ print(f" Before: mean={df['portfolio_size'].mean():.1f}, max={df['portfolio_size'].max():.0f}")
189
+ df['portfolio_size_raw'] = df['portfolio_size'].copy()
190
+ df['portfolio_size'] = np.log1p(df['portfolio_size'].clip(upper=20))
191
+ print(f" After log(clip(x,20)): mean={df['portfolio_size'].mean():.2f}, max={df['portfolio_size'].max():.2f}")
192
+ df['portfolio_size_bin'] = pd.cut(df['portfolio_size_raw'],
193
+ bins=[0, 5, 10, 15, 20, 100],
194
+ labels=[0, 1, 2, 3, 4]).astype(int)
195
+
196
+ # 2h. ED2 split (from V2)
197
+ def get_detailed_round(row):
198
+ sid = str(row.get('student_id', '')).replace('.0', '')
199
+ school = str(row.get('school', ''))
200
+ key = f"{sid}_{school}"
201
+ raw_round = round_lookup.get(key, '')
202
+ if 'Early Decision II' in raw_round:
203
+ return 'ED2'
204
+ elif 'Early Decision' in raw_round:
205
+ return 'ED1'
206
+ elif 'Restrictive Early Action' in raw_round:
207
+ return 'REA'
208
+ elif 'Early Action II' in raw_round or 'Early Action' in raw_round:
209
+ return 'EA'
210
+ elif 'Regular Decision' in raw_round:
211
+ return 'RD'
212
+ # Fall back to original round_cat
213
+ orig = str(row.get('round_cat', 'RD'))
214
+ if orig == 'ED':
215
+ return 'ED1'
216
+ return orig
217
+
218
+ df['round_cat_v2'] = df.apply(get_detailed_round, axis=1)
219
+ print(f"\n Round distribution (v2):")
220
+ print(df['round_cat_v2'].value_counts().to_string())
221
+
222
+ df['is_ed1'] = (df['round_cat_v2'] == 'ED1').astype(int)
223
+ df['is_ed2'] = (df['round_cat_v2'] == 'ED2').astype(int)
224
+ df['is_rea'] = (df['round_cat_v2'] == 'REA').astype(int)
225
+ df['is_early'] = df['round_cat_v2'].isin(['ED1', 'ED2', 'EA', 'REA']).astype(int)
226
+ df['round_cat'] = df['round_cat_v2']
227
 
228
  # ============================================================
229
+ # 3. PARSE LLM FEATURES
230
  # ============================================================
231
  act_scores = {}
232
  raw = llm_features_loaded.get('act_scores', {})
 
253
  sid = str(item.get('student_id', '')).replace('.0', '')
254
  school = str(item.get('school', ''))
255
  key = f"{sid}_{school}"
256
+ # FIX: Filter out score=1 entries (no supplement, not real scores)
257
+ oq = item.get('overall_quality', 0)
258
+ if isinstance(oq, (int, float)) and oq <= 1:
259
+ continue # Skip fake scores
260
  supp_scores[key] = item
261
  elif isinstance(raw, dict):
262
  for key, scores in raw.items():
263
  if isinstance(scores, dict):
264
+ oq = scores.get('overall_quality', 0)
265
+ if isinstance(oq, (int, float)) and oq <= 1:
266
+ continue # Skip fake scores
267
  supp_scores[key] = scores
268
+ print(f" Supp scores after filtering score=1: {len(supp_scores)} valid entries")
269
 
270
  major_diff = llm_features_loaded.get('major_diff', {})
271
  if isinstance(major_diff, list):
 
302
  'coherence_focus', 'overall_effectiveness']
303
 
304
  # ============================================================
305
+ # 4. DEFINE FEATURE GROUPS (adapted for v6 = v5 + restored v4 cols)
306
  # ============================================================
 
307
  STUDENT_LEVEL_NUMERIC = [
 
308
  'toefl', 'sat', 'gpa',
 
 
 
 
 
 
 
 
 
 
309
  'act_total_count', 'act_type_diversity',
 
310
  *[f'act_slot_pca_{i}' for i in range(20)],
 
311
  *[f'act_bert_pca_{i}' for i in range(16)],
312
+ # Restored from v4: ps_bert_pca
313
  *[f'ps_bert_pca_{i}' for i in range(16)],
 
314
  'honors_max_score', 'honors_avg_score', 'honors_min_score',
315
  'honors_count', 'honors_total_score',
316
+ 'honors_has_top_tier', 'honors_tier1_count', 'honors_tier2_count',
317
+ 'honors_has_national',
318
+ # NEW: honors quality ratio
319
+ 'honors_quality_ratio',
320
+ 'cuilu_hs_top10_rate', 'cuilu_hs_top20_rate',
321
+ 'cuilu_hs_top10_count', 'cuilu_hs_top20_count',
322
+ 'cuilu_hs_total',
 
 
323
  'cuilu_feeder_rank', 'cuilu_hs_type_rate', 'cuilu_region_rate',
324
+ # Restored from v4: hs_to_univ_hist
325
+ 'hs_to_univ_hist_rate', 'hs_to_univ_hist_rate_smoothed', 'hs_to_univ_hist_admits',
326
+ 'hs_overall_hist_rate',
327
+ # Restored from v4: summer features
328
+ 'summer_max_geili', 'summer_has_elite', 'summer_count',
329
+ 'summer_program_count', 'summer_difficulty_max',
330
  ]
331
 
332
+ # Add act_type_count columns dynamically
333
+ act_type_cols_in_data = [c for c in df.columns if c.startswith('act_type_count_')]
334
+ STUDENT_LEVEL_NUMERIC.extend(act_type_cols_in_data)
335
+
336
+ # Filter to only existing columns
337
+ STUDENT_LEVEL_NUMERIC = [c for c in STUDENT_LEVEL_NUMERIC if c in df.columns]
338
+ print(f"\n Student-level numeric features: {len(STUDENT_LEVEL_NUMERIC)}")
339
+
340
  KEY_STUDENT_FEATURES = [
341
  'toefl', 'sat', 'gpa',
342
  'honors_max_score', 'honors_avg_score', 'honors_count',
343
+ 'honors_quality_ratio',
344
  'act_type_diversity', 'act_total_count',
345
+ # Restored high-value features
346
+ 'hs_to_univ_hist_rate_smoothed',
347
+ 'summer_max_geili',
348
+ ]
349
+
350
+ LLM_INTERACTION_FEATURES = [
351
+ 'llm_act_mean', 'llm_act_max', 'llm_act_avg_power_index',
352
+ 'supp_mean', 'supp_max', 'ps_mean',
353
+ 'major_difficulty',
354
  ]
355
 
356
  # ============================================================
357
+ # 5. BUILD FEATURES
358
  # ============================================================
359
  def build_features_base(df):
360
+ """Build base features WITHOUT residualization."""
 
361
  df = df.copy()
362
 
363
  df['is_partial_year'] = (df['year'] == 2025).astype(int)
 
368
  for dim in ACT_DIMS:
369
  col_name = f'llm_act_{dim}'
370
  df[col_name] = df['sid_str'].map(
371
+ lambda s, d=dim: safe_num(act_scores.get(s, {}).get(d, np.nan)))
372
 
373
+ # LLM Supp features
374
  def get_supp_score(row, dim):
375
  key = f"{row['sid_str']}_{row['school']}"
376
+ return safe_num(supp_scores.get(key, {}).get(dim, np.nan))
377
  for dim in SUPP_DIMS:
378
  col_name = f'supp_{dim}'
379
  df[col_name] = df.apply(lambda r, d=dim: get_supp_score(r, d), axis=1)
380
 
381
+ # Major difficulty
382
  def get_major_diff(row):
383
  key = f"{row['school']}_{row['major_cat']}"
384
+ return safe_num(major_diff.get(key, {}).get('difficulty_score', np.nan))
385
  df['major_difficulty'] = df.apply(get_major_diff, axis=1)
386
 
387
  # PS Yale scores
388
  for dim in PS_DIMS:
389
  col_name = f'ps_{dim}'
390
  df[col_name] = df['sid_str'].map(
391
+ lambda s, d=dim: safe_num(ps_yale.get(s, {}).get(d, np.nan)))
392
 
393
+ # Aggregates (use NaN-aware operations)
394
  llm_act_cols = [f'llm_act_{d}' for d in ACT_DIMS]
395
+ valid_act = df[llm_act_cols]
396
  df['llm_act_mean'] = valid_act.mean(axis=1)
397
  df['llm_act_max'] = valid_act.max(axis=1)
398
  df['llm_act_n_valid'] = valid_act.notna().sum(axis=1)
399
 
400
  supp_num_cols = [f'supp_{d}' for d in SUPP_DIMS if d not in ['has_red_flag']]
401
+ valid_supp = df[supp_num_cols]
402
  df['supp_mean'] = valid_supp.mean(axis=1)
403
  df['supp_max'] = valid_supp.max(axis=1)
404
 
405
  ps_cols = [f'ps_{d}' for d in PS_DIMS]
406
+ valid_ps = df[ps_cols]
407
  df['ps_mean'] = valid_ps.mean(axis=1)
408
 
409
+ # Basic interactions (NaN-safe: NaN * anything = NaN, which is fine)
410
  df['toefl_x_sat'] = df['toefl'] * df['sat'] / 10000.0
411
  df['gpa_x_toefl'] = df['gpa'] * df['toefl'] / 100.0
412
+ df['llm_act_x_supp'] = df['llm_act_mean'] * df['supp_mean']
413
 
414
  if 'honors_avg_score' in df.columns:
415
+ df['honors_x_sat'] = df['honors_avg_score'] * df['sat'] / 1600
416
+ df['honors_x_toefl'] = df['honors_avg_score'] * df['toefl'] / 120
417
 
418
  if 'cuilu_hs_top10_rate' in df.columns and 'taste_score_sensitivity' in df.columns:
419
+ df['cuilu_x_taste'] = df['cuilu_hs_top10_rate'] * df['taste_score_sensitivity']
420
 
421
+ # Categoricals - with round_cat v2 (EA/ED1/ED2/REA/RD)
422
  cat_cols = ['school', 'round_cat', 'major_cat', 'hs_cat', 'year_cat', 'hs_name', 'province']
423
  cat_cols = [c for c in cat_cols if c in df.columns]
424
 
 
439
  return df, cat_cols
440
 
441
 
442
+ def add_residualized_features(df, train_mask, cat_cols, selected_features=None):
443
+ """Add residualized + interaction + ED boost features using ONLY training data statistics.
444
+ KEY FIX: Residualization uses only non-NaN values for school means."""
445
  df = df.copy()
446
 
447
+ # Step 1: Bayesian-smoothed school_base_rate
448
  train_df = df[train_mask]
449
+ global_rate = train_df[TARGET].mean()
450
+
451
  school_stats = train_df.groupby('school').agg(
452
+ school_raw_rate=(TARGET, 'mean'),
453
  school_n_apps=(TARGET, 'count'),
454
  school_n_admits=(TARGET, 'sum'),
455
  ).reset_index()
456
 
457
+ SMOOTH_STRENGTH = 30
458
+ school_stats['school_base_rate'] = (
459
+ (school_stats['school_raw_rate'] * school_stats['school_n_apps'] + global_rate * SMOOTH_STRENGTH) /
460
+ (school_stats['school_n_apps'] + SMOOTH_STRENGTH)
461
+ )
462
+
463
+ df = df.merge(school_stats[['school', 'school_base_rate', 'school_n_apps', 'school_n_admits']],
464
+ on='school', how='left')
465
  df['school_base_rate'] = df['school_base_rate'].fillna(global_rate)
466
  df['school_n_apps'] = df['school_n_apps'].fillna(0)
467
  df['school_n_admits'] = df['school_n_admits'].fillna(0)
468
 
469
+ # Step 1b: ED boost per school
470
+ ed1_mask = train_df['is_ed1'] == 1
471
+ rd_mask = train_df['is_early'] == 0
472
 
473
+ ed1_school_rates = train_df[ed1_mask].groupby('school')[TARGET].mean()
474
+ rd_school_rates = train_df[rd_mask].groupby('school')[TARGET].mean()
475
+
476
+ ed_boost_map = {}
477
+ for school in ed1_school_rates.index:
478
+ if school in rd_school_rates.index:
479
+ ed_boost_map[school] = ed1_school_rates[school] - rd_school_rates[school]
480
+ df['school_ed_boost'] = df['school'].map(ed_boost_map).fillna(0)
481
+
482
+ ed2_mask = train_df['is_ed2'] == 1
483
+ ed2_school_rates = train_df[ed2_mask].groupby('school')[TARGET].mean()
484
+ ed2_boost_map = {}
485
+ for school in ed2_school_rates.index:
486
+ if school in rd_school_rates.index:
487
+ ed2_boost_map[school] = ed2_school_rates[school] - rd_school_rates[school]
488
+ df['school_ed2_boost'] = df['school'].map(ed2_boost_map).fillna(0)
489
+
490
+ # Step 2: Residualize student features
491
+ # KEY FIX: Use only non-NaN values for school means
492
+ student_feat_available = [c for c in STUDENT_LEVEL_NUMERIC if c in df.columns]
493
 
 
494
  resid_cols = []
495
  for col in student_feat_available:
496
  resid_col = f'{col}_resid'
497
+ # Compute school mean using ONLY non-NaN training values
498
+ school_mean_series = train_df.groupby('school')[col].mean() # NaN excluded by default
499
+ col_school_mean = df['school'].map(school_mean_series)
500
+ # Residual: student value - school mean (NaN if either is NaN)
501
+ df[resid_col] = df[col] - col_school_mean
502
  resid_cols.append(resid_col)
503
 
504
+ # Step 3: Explicit interactions (student feature x school_base_rate)
505
  interaction_cols = []
506
  for col in KEY_STUDENT_FEATURES:
507
  if col in df.columns:
508
  int_col = f'{col}_x_school_rate'
509
+ df[int_col] = df[col] * df['school_base_rate'] # NaN propagates naturally
510
  interaction_cols.append(int_col)
511
 
 
512
  resid_col = f'{col}_resid'
513
  if resid_col in df.columns:
514
  int_resid_col = f'{col}_resid_x_rate'
515
  df[int_resid_col] = df[resid_col] * df['school_base_rate']
516
  interaction_cols.append(int_resid_col)
517
 
518
+ # Step 3b: LLM feature x school_base_rate interactions
519
+ for col in LLM_INTERACTION_FEATURES:
520
+ if col in df.columns:
521
+ int_col = f'{col}_x_school_rate'
522
+ df[int_col] = df[col] * df['school_base_rate']
523
+ interaction_cols.append(int_col)
524
+
525
+ # Step 3c: portfolio_size x school_base_rate interaction
526
+ if 'portfolio_size' in df.columns:
527
+ df['portfolio_x_school_rate'] = df['portfolio_size'] * df['school_base_rate']
528
+ interaction_cols.append('portfolio_x_school_rate')
529
+
530
+ # Step 3d: ED flag x school_ed_boost interaction
531
+ if 'is_ed1' in df.columns:
532
+ df['ed1_x_ed_boost'] = df['is_ed1'] * df['school_ed_boost']
533
+ interaction_cols.append('ed1_x_ed_boost')
534
+ if 'is_ed2' in df.columns:
535
+ df['ed2_x_ed2_boost'] = df['is_ed2'] * df['school_ed2_boost']
536
+ interaction_cols.append('ed2_x_ed2_boost')
537
+
538
+ # Step 3e: has_sat/has_toefl/has_gpa interactions with school_base_rate
539
+ for flag in ['has_sat', 'has_toefl', 'has_gpa']:
540
+ if flag in df.columns:
541
+ int_col = f'{flag}_x_school_rate'
542
+ df[int_col] = df[flag] * df['school_base_rate']
543
+ interaction_cols.append(int_col)
544
+
545
+ # Step 4: Student percentile within school (NaN-safe)
546
  pctile_cols = []
547
+ for col in ['toefl', 'sat', 'gpa', 'honors_max_score',
548
+ 'llm_act_mean', 'supp_mean']:
549
  if col not in df.columns:
550
  continue
551
  pctile_col = f'{col}_school_pctile'
 
552
  school_distributions = {}
553
  for school_id in train_df['school'].unique():
554
  vals = train_df[train_df['school'] == school_id][col].dropna().values
555
  if len(vals) > 2:
556
  school_distributions[school_id] = vals
557
 
558
+ def compute_pctile(row, col=col, sd=school_distributions):
559
  school_id = row['school']
560
  val = row[col]
561
+ if pd.isna(val) or school_id not in sd:
562
+ return np.nan # Return NaN instead of 0.5
563
+ dist = sd[school_id]
564
  return np.mean(dist <= val)
565
 
566
  df[pctile_col] = df.apply(compute_pctile, axis=1)
567
  pctile_cols.append(pctile_col)
568
 
569
+ # Step 5: Student competitiveness score (NaN-safe)
570
+ if all(c in df.columns for c in ['toefl', 'sat', 'honors_max_score']):
571
+ # Use NaN-safe computation
572
+ components = []
573
+ weights = []
574
+ for col, w, scale in [('toefl', 0.3, 120), ('sat', 0.3, 1600),
575
+ ('honors_max_score', 0.2, 10), ('llm_act_mean', 0.2, 10)]:
576
+ if col in df.columns:
577
+ components.append(df[col] / scale)
578
+ weights.append(w)
579
+ if components:
580
+ strength_df = pd.DataFrame(components).T
581
+ df['student_strength'] = strength_df.mean(axis=1) # NaN-safe mean
582
+ df['strength_vs_school'] = df['student_strength'] - (1 - df['school_base_rate'])
583
+
584
  # Build final feature list
585
  num_cols = [c for c in df.columns if df[c].dtype in ['float64', 'int64', 'float32', 'int32']
586
  and c not in [TARGET, 'student_id', 'year', 'Unnamed: 0']]
587
 
588
  all_feat = list(set(num_cols + cat_cols))
589
  feature_cols = list(dict.fromkeys([c for c in all_feat if c in df.columns]))
590
+ for remove in [TARGET, 'student_id', 'year', 'sid_str', 'Unnamed: 0', 'portfolio_size_raw']:
591
  if remove in feature_cols:
592
  feature_cols.remove(remove)
593
 
 
595
  to_drop = [c for c in feature_cols if df[c].nunique() <= 1]
596
  feature_cols = [c for c in feature_cols if c not in to_drop]
597
 
598
+ # Apply feature selection if provided
599
+ if selected_features is not None:
600
+ must_keep = set(cat_cols) | {'school_base_rate', 'school_n_apps', 'school_n_admits',
601
+ 'student_strength', 'strength_vs_school',
602
+ 'school_ed_boost', 'school_ed2_boost',
603
+ 'is_ed1', 'is_ed2', 'is_rea', 'is_early',
604
+ 'ed1_x_ed_boost', 'ed2_x_ed2_boost',
605
+ 'has_sat', 'has_toefl', 'has_gpa',
606
+ 'portfolio_size', 'portfolio_size_bin', 'portfolio_x_school_rate'}
607
+ feature_cols = [c for c in feature_cols if c in selected_features or c in must_keep]
608
+
609
+ # KEY CHANGE: Do NOT fill NaN for CatBoost - it handles NaN natively
610
+ # Only fill NaN for LGB/XGB later, and only for non-cat columns
611
+ # For now, just handle inf
612
  for c in feature_cols:
613
  if df[c].dtype in ['float64', 'float32']:
614
+ df[c] = df[c].replace([np.inf, -np.inf], np.nan)
615
 
616
  cat_indices = [feature_cols.index(c) for c in cat_cols if c in feature_cols]
617
 
618
+ new_feat_count = len(resid_cols) + len(interaction_cols) + len(pctile_cols) + 5
619
+ print(f" Resid features: {len(resid_cols)} resid + {len(interaction_cols)} interact + {len(pctile_cols)} pctile = {new_feat_count} new, total={len(feature_cols)}")
620
 
621
  return df, feature_cols, cat_cols, cat_indices
622
 
 
627
  df_base, cat_cols = build_features_base(df)
628
  print(f"\nBase features built. Shape: {df_base.shape}")
629
 
630
+ # Quick NaN summary
631
+ print(f"\n NaN summary after fixes:")
632
+ for col in ['sat', 'toefl', 'gpa']:
633
+ nan_pct = df_base[col].isna().mean() * 100
634
+ print(f" {col}: {nan_pct:.1f}% NaN")
635
+
636
  y = df_base[TARGET].values
637
  groups = df_base['student_id'].values
638
 
639
  # ============================================================
640
+ # 7. STAGE 1: FEATURE IMPORTANCE ESTIMATION
641
+ # ============================================================
642
+ print(f"\n{'='*70}")
643
+ print(f" STAGE 1: FEATURE IMPORTANCE ESTIMATION")
644
+ print(f"{'='*70}")
645
+
646
+ stage1_fi = []
647
+ gkf_s1 = GroupKFold(n_splits=5)
648
+ for fold, (tr_idx, va_idx) in enumerate(gkf_s1.split(df_base, y, groups)):
649
+ train_mask = pd.Series(False, index=df_base.index)
650
+ train_mask.iloc[tr_idx] = True
651
+
652
+ df_fold, feat_cols_f, cat_cols_f, cat_idx_f = add_residualized_features(
653
+ df_base, train_mask, cat_cols)
654
+
655
+ X_tr = df_fold[feat_cols_f].iloc[tr_idx]
656
+ X_va = df_fold[feat_cols_f].iloc[va_idx]
657
+ y_tr = y[tr_idx]
658
+ y_va = y[va_idx]
659
+
660
+ for c in cat_cols_f:
661
+ if c in X_tr.columns:
662
+ X_tr[c] = X_tr[c].astype(int)
663
+ X_va[c] = X_va[c].astype(int)
664
+ # CatBoost handles NaN natively - don't fill
665
+
666
+ cb = CatBoostClassifier(
667
+ iterations=500, depth=6, learning_rate=0.05,
668
+ l2_leaf_reg=7, random_seed=42, verbose=0,
669
+ cat_features=cat_idx_f, eval_metric='AUC',
670
+ early_stopping_rounds=50)
671
+ pool_tr = Pool(X_tr, y_tr, cat_features=cat_idx_f)
672
+ pool_va = Pool(X_va, y_va, cat_features=cat_idx_f)
673
+ cb.fit(pool_tr, eval_set=pool_va, verbose=0)
674
+
675
+ fi = cb.get_feature_importance()
676
+ stage1_fi.append(fi)
677
+
678
+ auc = roc_auc_score(y_va, cb.predict_proba(Pool(X_va, cat_features=cat_idx_f))[:, 1])
679
+ print(f" Fold {fold+1}/5: AUC={auc:.4f}, Features={len(feat_cols_f)}")
680
+
681
+ if fold == 0:
682
+ all_feature_names = feat_cols_f
683
+
684
+ del cb, pool_tr, pool_va, df_fold; gc.collect()
685
+
686
+ # Select top features
687
+ avg_fi = np.mean(stage1_fi, axis=0)
688
+ fi_pairs = sorted(zip(all_feature_names, avg_fi), key=lambda x: -x[1])
689
+
690
+ selected_set = set(cat_cols)
691
+ n_added = 0
692
+ for fname, imp in fi_pairs:
693
+ if fname not in cat_cols:
694
+ selected_set.add(fname)
695
+ n_added += 1
696
+ if n_added >= FEATURE_SELECT_TOP_N:
697
+ break
698
+
699
+ print(f"\n Feature selection: {len(all_feature_names)} -> {len(selected_set)} features")
700
+ print(f" Top 20 features:")
701
+ for i, (fname, imp) in enumerate(fi_pairs[:20]):
702
+ marker = ""
703
+ if '_resid' in fname: marker = " [R]"
704
+ elif '_x_school_rate' in fname or '_resid_x_rate' in fname or '_x_ed' in fname: marker = " [I]"
705
+ elif '_school_pctile' in fname: marker = " [P]"
706
+ elif 'school_base_rate' in fname: marker = " [S]"
707
+ elif 'ed_boost' in fname: marker = " [ED]"
708
+ print(f" {i+1:3d}. {fname:<50s} {imp:>8.2f}{marker}")
709
+
710
+ # ============================================================
711
+ # 8. TEMPORAL VALIDATION WITH SELECTED FEATURES
712
  # ============================================================
713
  print(f"\n{'='*70}")
714
+ print(f" TEMPORAL VALIDATION (2020-2023 -> 2024) WITH FEATURE SELECTION")
715
  print(f"{'='*70}")
716
 
717
  mask_train_temporal = df_base['year'].isin([2020, 2021, 2022, 2023])
 
719
 
720
  temporal_results = {}
721
  if mask_test_temporal.sum() > 0:
 
722
  df_temporal, feat_cols_t, cat_cols_t, cat_idx_t = add_residualized_features(
723
+ df_base, mask_train_temporal, cat_cols, selected_features=selected_set)
724
 
725
  X_t = df_temporal[feat_cols_t].copy()
726
  for c in cat_cols_t:
727
  if c in X_t.columns:
728
  X_t[c] = X_t[c].astype(int)
 
729
 
730
  X_tr_t = X_t[mask_train_temporal]
731
  X_te_t = X_t[mask_test_temporal]
732
  y_tr_t = y[mask_train_temporal]
733
  y_te_t = y[mask_test_temporal]
734
 
735
+ # For LGB/XGB: fill NaN with -999 (a value they can split on)
736
+ X_tr_t_filled = X_tr_t.fillna(-999)
737
+ X_te_t_filled = X_te_t.fillna(-999)
738
+
739
  print(f" Train: {len(X_tr_t)}, Test: {len(X_te_t)}, Features: {len(feat_cols_t)}")
740
 
741
  for seed in SEEDS:
742
+ # CatBoost: native NaN handling
743
  cb_t = CatBoostClassifier(
744
+ iterations=1000, depth=6, learning_rate=0.03,
745
+ l2_leaf_reg=7, random_seed=seed, verbose=0,
746
  cat_features=cat_idx_t, eval_metric='AUC',
747
+ early_stopping_rounds=100, min_data_in_leaf=10)
748
  pool_tr = Pool(X_tr_t, y_tr_t, cat_features=cat_idx_t)
749
  pool_te = Pool(X_te_t, y_te_t, cat_features=cat_idx_t)
750
  cb_t.fit(pool_tr, eval_set=pool_te, verbose=0)
751
  cb_pred = cb_t.predict_proba(Pool(X_te_t, cat_features=cat_idx_t))[:, 1]
752
  del cb_t; gc.collect()
753
 
754
+ # LGB: use filled data
755
+ lgb_tr = lgb.Dataset(X_tr_t_filled.values, y_tr_t, categorical_feature=cat_idx_t)
756
+ lgb_va = lgb.Dataset(X_te_t_filled.values, y_te_t, categorical_feature=cat_idx_t, reference=lgb_tr)
757
  lgb_params = {
758
  'objective': 'binary', 'metric': 'auc', 'verbosity': -1,
759
+ 'learning_rate': 0.03, 'num_leaves': 63, 'max_depth': 6,
760
+ 'min_child_samples': 25, 'reg_alpha': 0.3, 'reg_lambda': 2.0,
761
+ 'feature_fraction': 0.7, 'bagging_fraction': 0.8, 'bagging_freq': 5,
762
  'seed': seed
763
  }
764
+ lgb_model = lgb.train(lgb_params, lgb_tr, num_boost_round=1500,
765
  valid_sets=[lgb_va],
766
+ callbacks=[lgb.early_stopping(100), lgb.log_evaluation(0)])
767
+ lgb_pred = lgb_model.predict(X_te_t_filled.values)
768
  del lgb_model; gc.collect()
769
 
770
+ # XGB: use filled data
771
+ dtrain = xgb.DMatrix(X_tr_t_filled.values, label=y_tr_t, enable_categorical=False)
772
+ dtest = xgb.DMatrix(X_te_t_filled.values, label=y_te_t, enable_categorical=False)
773
  xgb_params = {
774
  'objective': 'binary:logistic', 'eval_metric': 'auc',
775
+ 'max_depth': 6, 'learning_rate': 0.03,
776
+ 'subsample': 0.8, 'colsample_bytree': 0.7,
777
+ 'reg_alpha': 0.3, 'reg_lambda': 2.0,
778
+ 'min_child_weight': 5,
779
  'seed': seed, 'verbosity': 0
780
  }
781
+ xgb_model = xgb.train(xgb_params, dtrain, num_boost_round=1500,
782
  evals=[(dtest, 'val')],
783
+ early_stopping_rounds=100, verbose_eval=False)
784
  xgb_pred = xgb_model.predict(dtest)
785
  del xgb_model, dtrain, dtest; gc.collect()
786
 
787
+ blend = 0.45 * cb_pred + 0.20 * lgb_pred + 0.35 * xgb_pred
788
  temporal_results[seed] = {
789
  'cb': float(roc_auc_score(y_te_t, cb_pred)),
790
  'lgb': float(roc_auc_score(y_te_t, lgb_pred)),
 
794
  print(f" Seed {seed}: CB={temporal_results[seed]['cb']:.4f} LGB={temporal_results[seed]['lgb']:.4f} XGB={temporal_results[seed]['xgb']:.4f} Blend={temporal_results[seed]['blend']:.4f}")
795
 
796
  avg_temporal = np.mean([v['blend'] for v in temporal_results.values()])
797
+ print(f"\n AVG Temporal Blend: {avg_temporal:.4f} (V37.3: 0.8410, V38.2-PRO-V3: 0.8528)")
798
  print(f" Delta vs V37.3: {avg_temporal - 0.8410:+.4f}")
799
+ print(f" Delta vs V38.2-PRO-V3: {avg_temporal - 0.8528:+.4f}")
800
 
801
  del df_temporal, X_t; gc.collect()
802
  else:
803
  avg_temporal = 0.0
804
 
805
  # ============================================================
806
+ # 9. STAGE 2: MULTI-SEED GROUPKFOLD
807
  # ============================================================
808
  print(f"\n{'='*70}")
809
+ print(f" STAGE 2: MULTI-SEED GROUPKFOLD ({len(SEEDS)} seeds x {N_FOLDS} folds)")
810
  print(f"{'='*70}")
811
 
812
  all_cb_oof = []
 
823
  xgb_oof = np.zeros(len(df_base))
824
 
825
  for fold, (tr_idx, va_idx) in enumerate(gkf.split(df_base, y, groups)):
 
826
  train_mask = pd.Series(False, index=df_base.index)
827
  train_mask.iloc[tr_idx] = True
828
 
 
829
  df_fold, feat_cols_f, cat_cols_f, cat_idx_f = add_residualized_features(
830
+ df_base, train_mask, cat_cols, selected_features=selected_set)
831
 
832
  if feature_cols_final is None:
833
  feature_cols_final = feat_cols_f
834
+ print(f" Total features after selection: {len(feat_cols_f)}")
835
 
836
  X_fold = df_fold[feat_cols_f].copy()
837
  for c in cat_cols_f:
838
  if c in X_fold.columns:
839
  X_fold[c] = X_fold[c].astype(int)
 
840
 
841
  X_tr_df = X_fold.iloc[tr_idx]
842
  X_va_df = X_fold.iloc[va_idx]
843
  y_tr = y[tr_idx]
844
  y_va = y[va_idx]
845
 
846
+ # CatBoost: native NaN
847
  cb = CatBoostClassifier(
848
+ iterations=1500, depth=6, learning_rate=0.03,
849
+ l2_leaf_reg=7, random_seed=seed, verbose=0,
850
  cat_features=cat_idx_f, eval_metric='AUC',
851
+ early_stopping_rounds=100, min_data_in_leaf=10)
852
  pool_tr = Pool(X_tr_df, y_tr, cat_features=cat_idx_f)
853
  pool_va = Pool(X_va_df, y_va, cat_features=cat_idx_f)
854
  cb.fit(pool_tr, eval_set=pool_va, verbose=0)
 
859
  all_fi.append(cb.get_feature_importance())
860
  del cb, pool_tr, pool_va; gc.collect()
861
 
862
+ # LGB/XGB: fill NaN
863
+ X_tr_filled = X_tr_df.fillna(-999).values
864
+ X_va_filled = X_va_df.fillna(-999).values
865
+
866
+ lgb_tr = lgb.Dataset(X_tr_filled, y_tr, categorical_feature=cat_idx_f)
867
+ lgb_va_ds = lgb.Dataset(X_va_filled, y_va, categorical_feature=cat_idx_f, reference=lgb_tr)
868
  lgb_params = {
869
  'objective': 'binary', 'metric': 'auc', 'verbosity': -1,
870
+ 'learning_rate': 0.03, 'num_leaves': 63, 'max_depth': 6,
871
+ 'min_child_samples': 25, 'reg_alpha': 0.3, 'reg_lambda': 2.0,
872
+ 'feature_fraction': 0.7, 'bagging_fraction': 0.8, 'bagging_freq': 5,
873
  'seed': seed
874
  }
875
+ lgb_model = lgb.train(lgb_params, lgb_tr, num_boost_round=1500,
876
  valid_sets=[lgb_va_ds],
877
+ callbacks=[lgb.early_stopping(100), lgb.log_evaluation(0)])
878
+ lgb_pred = lgb_model.predict(X_va_filled)
879
  lgb_oof[va_idx] = lgb_pred
880
  del lgb_model; gc.collect()
881
 
882
+ dtrain = xgb.DMatrix(X_tr_filled, label=y_tr)
883
+ dval = xgb.DMatrix(X_va_filled, label=y_va)
 
884
  xgb_params = {
885
  'objective': 'binary:logistic', 'eval_metric': 'auc',
886
+ 'max_depth': 6, 'learning_rate': 0.03,
887
+ 'subsample': 0.8, 'colsample_bytree': 0.7,
888
+ 'reg_alpha': 0.3, 'reg_lambda': 2.0,
889
+ 'min_child_weight': 5,
890
  'seed': seed, 'verbosity': 0
891
  }
892
+ xgb_model = xgb.train(xgb_params, dtrain, num_boost_round=1500,
893
  evals=[(dval, 'val')],
894
+ early_stopping_rounds=100, verbose_eval=False)
895
  xgb_pred = xgb_model.predict(dval)
896
  xgb_oof[va_idx] = xgb_pred
897
  del xgb_model, dtrain, dval, df_fold, X_fold; gc.collect()
 
909
  all_xgb_oof.append(xgb_oof)
910
 
911
  # ============================================================
912
+ # 10. ENSEMBLE & BLEND
913
  # ============================================================
914
  print(f"\n{'='*70}")
915
  print(f" ENSEMBLE RESULTS")
 
923
  lgb_final_auc = roc_auc_score(y, lgb_avg)
924
  xgb_final_auc = roc_auc_score(y, xgb_avg)
925
 
926
+ print(f" CB {len(SEEDS)}-seed avg: {cb_final_auc:.4f}")
927
+ print(f" LGB {len(SEEDS)}-seed avg: {lgb_final_auc:.4f}")
928
+ print(f" XGB {len(SEEDS)}-seed avg: {xgb_final_auc:.4f}")
929
 
930
  best_auc = 0
931
+ best_weights = (0.45, 0.20, 0.35)
932
  for w_cb in np.arange(0.2, 0.7, 0.05):
933
+ for w_lgb in np.arange(0.05, 0.5, 0.05):
934
  w_xgb = 1.0 - w_cb - w_lgb
935
  if w_xgb < 0.05: continue
936
  blend = w_cb * cb_avg + w_lgb * lgb_avg + w_xgb * xgb_avg
 
940
  best_weights = (w_cb, w_lgb, w_xgb)
941
 
942
  print(f"\n Best 3-model blend: {best_auc:.4f}")
943
+ print(f" Delta vs V37.3: {best_auc - 0.8697:+.4f}")
944
+ print(f" Delta vs V38.2-PRO-V3: {best_auc - 0.8743:+.4f}")
945
  print(f" Weights: CB={best_weights[0]:.2f} LGB={best_weights[1]:.2f} XGB={best_weights[2]:.2f}")
946
 
947
  rank_blend = (rankdata(cb_avg) + rankdata(lgb_avg) + rankdata(xgb_avg)) / 3
948
  rank_auc = roc_auc_score(y, rank_blend)
949
  print(f" Rank blend: {rank_auc:.4f}")
950
 
951
+ final_blend_prob = best_weights[0] * cb_avg + best_weights[1] * lgb_avg + best_weights[2] * xgb_avg
952
+ final_auc = roc_auc_score(y, final_blend_prob)
953
+ final_brier = brier_score_loss(y, np.clip(final_blend_prob, 1e-7, 1-1e-7))
954
+ final_logloss = log_loss(y, np.clip(final_blend_prob, 1e-7, 1-1e-7))
955
 
956
  print(f"\n FINAL METRICS:")
957
+ print(f" AUC: {final_auc:.4f} (V37.3: 0.8697, V38.2-PRO-V3: 0.8743)")
958
  print(f" Brier: {final_brier:.4f}")
959
  print(f" LogLoss: {final_logloss:.4f}")
960
 
961
  # ============================================================
962
+ # 11. FEATURE IMPORTANCE
963
  # ============================================================
964
  print(f"\n{'='*70}")
965
  print(f" FEATURE IMPORTANCE (avg across seeds)")
 
968
  if feature_cols_final and all_fi:
969
  avg_fi = np.mean(all_fi, axis=0)
970
  fi_pairs = sorted(zip(feature_cols_final, avg_fi), key=lambda x: -x[1])
971
+
972
  print(f" {'Rank':<5s} {'Feature':<50s} {'Importance':>10s}")
973
  print(f" {'-'*5} {'-'*50} {'-'*10}")
974
  for i, (fname, imp) in enumerate(fi_pairs[:50]):
975
  marker = ""
976
+ if '_resid' in fname: marker = " [RESID]"
977
+ elif '_x_school_rate' in fname or '_resid_x_rate' in fname or '_x_ed' in fname: marker = " [INTERACT]"
978
+ elif '_school_pctile' in fname: marker = " [PCTILE]"
979
+ elif fname.startswith('school_base_rate'): marker = " [SCHOOL_RATE]"
980
+ elif 'ed_boost' in fname or 'ed2_boost' in fname: marker = " [ED_BOOST]"
981
+ elif fname.startswith('has_'): marker = " [FLAG]"
 
 
982
  print(f" {i+1:<5d} {fname:<50s} {imp:>10.2f}{marker}")
983
 
984
+ resid_in_top30 = sum(1 for f, _ in fi_pairs[:30]
985
+ if '_resid' in f or '_x_school_rate' in f or '_school_pctile' in f or 'school_base_rate' in f)
986
  print(f"\n Residualized/interaction features in top 30: {resid_in_top30}")
987
 
988
  # ============================================================
989
+ # 12. SAVE RESULTS
990
  # ============================================================
991
  elapsed = time.time() - start_time
992
 
993
  results = {
994
+ 'version': 'V38.2-pro-v4',
995
  'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
996
  'elapsed_minutes': elapsed / 60,
997
+ 'changes': [
998
+ 'Use v5 feature matrix (GPA fixed: 40.3% coverage)',
999
+ 'SAT=0 -> NaN + has_sat flag',
1000
+ 'TOEFL=0 -> NaN + has_toefl flag',
1001
+ 'GPA=0 -> NaN + has_gpa flag',
1002
+ '-1 -> NaN for sentinel columns',
1003
+ 'Residualization uses only non-NaN values',
1004
+ 'CatBoost native NaN handling',
1005
+ 'LGB/XGB use -999 for NaN',
1006
+ 'Percentile returns NaN instead of 0.5 for missing',
1007
+ ],
1008
  'comparison': {
1009
  'v37_3': {'auc': 0.8697, 'temporal_auc': 0.8410},
1010
+ 'v38_2_pro_v3': {'auc': 0.8743, 'temporal_auc': 0.8528},
1011
  },
1012
  'temporal_validation': {
1013
  'per_seed': temporal_results,
 
1027
  'feature_importance': [[f, float(i)] for f, i in fi_pairs[:50]] if feature_cols_final and all_fi else [],
1028
  }
1029
 
1030
+ with open(os.path.join(OUTPUT_DIR, 'v38_2_pro_v4_results.json'), 'w') as f:
1031
  json.dump(results, f, indent=2)
1032
 
1033
  oof_df = df_base[['student_id', 'school', 'year', TARGET]].copy()
1034
  oof_df['cb_pred'] = cb_avg
1035
  oof_df['lgb_pred'] = lgb_avg
1036
  oof_df['xgb_pred'] = xgb_avg
1037
+ oof_df['final_pred'] = final_blend_prob
1038
+ oof_df.to_csv(os.path.join(OUTPUT_DIR, 'v38_2_pro_v4_oof_predictions.csv'), index=False)
1039
 
1040
  print(f"\n{'='*70}")
1041
+ print(f" V38.2-PRO-V4 COMPLETE")
1042
  print(f" Total time: {elapsed/60:.1f} minutes")
1043
  print(f" Features: {len(feature_cols_final) if feature_cols_final else 'N/A'}")
1044
+ print(f" GroupKFold AUC: {final_auc:.4f} (V37.3: 0.8697, V38.2-PRO-V3: 0.8743)")
1045
+ print(f" Temporal AUC: {avg_temporal:.4f} (V37.3: 0.8410, V38.2-PRO-V2: 0.8469)")
1046
  print(f"{'='*70}")