Setting the file. One moment.
Analysis · Datanalysis Credit Risk · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page This file
Number 126.1
Position 1 of 3
Type Python
Size 53 KB
Lines 1,223 references/ analysis.py
Python · 1,223 lines · 53 KB
datetime
import
datetime
11 import lightgbm as lgb
12 from sklearn.model_selection import train_test_split
13 from sklearn.metrics import roc_auc_score
14 from joblib import Parallel, delayed
15
16
17 def drop_abnormal_ym (data: pd.DataFrame, min_ym_bad_sample: int = 1 ,
18 min_ym_sample: int = 500 ) -> tuple :
19 """Filter abnormal months - overall statistics, not by organization"""
20 stat = data.groupby( 'new_date_ym' ).agg(
21 bad_cnt = ( 'new_target' , 'sum' ),
22 total = ( 'new_target' , 'count' )
23 ).reset_index()
24
25 abnormal = stat[(stat[ 'bad_cnt' ] < min_ym_bad_sample) | (stat[ 'total' ] < min_ym_sample)]
26 abnormal = abnormal.rename( columns = { 'new_date_ym' : '年月' })
27 abnormal[ '去除条件' ] = abnormal.apply(
28 lambda x: f 'bad sample count { x[ "bad_cnt" ] } less than { min_ym_bad_sample } ' if x[ 'bad_cnt' ] < min_ym_bad_sample else f 'total sample count { x[ "total" ] } less than { min_ym_sample } ' , axis = 1
29 )
30
31 if len (abnormal) > 0 :
32 data = data[ ~ data[ 'new_date_ym' ].isin(abnormal[ '年月' ])]
33
34 # Remove empty rows
35 abnormal = abnormal.dropna( how = 'all' )
36 abnormal = abnormal.reset_index( drop = True )
37
38 return data, abnormal
39
40
41 def drop_highmiss_features (data: pd.DataFrame, miss_channel: pd.DataFrame,
42 threshold: float = 0.6 ) -> tuple :
43 """Drop high missing rate features"""
44 high_miss = miss_channel[miss_channel[ '整体缺失率' ] > threshold].copy()
45 high_miss[ '缺失率' ] = high_miss[ '整体缺失率' ]
46
47 # Modify removal condition to show specific missing rate value
48 high_miss[ '去除条件' ] = high_miss.apply(
49 lambda x: f 'overall missing rate is { x[ "缺失率" ] :.4f} , exceeds threshold { threshold } ' , axis = 1
50 )
51
52 # Remove empty rows
53 high_miss = high_miss.dropna( how = 'all' )
54 high_miss = high_miss.reset_index( drop = True )
55
56 # Drop high missing rate features
57 if len (high_miss) > 0 and '变量' in high_miss.columns:
58 to_drop = high_miss[ '变量' ].tolist()
59 data = data.drop( columns = [c for c in to_drop if c in data.columns])
60
61 return data, high_miss[[ '变量' , '缺失率' , '去除条件' ]]
62
63
64 def drop_lowiv_features (data: pd.DataFrame, features: List[ str ],
65 overall_iv_threshold: float = 0.05 , org_iv_threshold: float = 0.02 ,
66 max_org_threshold: int = 8 , n_jobs: int = 4 ) -> tuple :
67 """Drop low IV features - multi-process version, returns IV details and IV processing table
68
69 Args:
70 overall_iv_threshold: Overall IV threshold, values below this are recorded in IV processing table
71 org_iv_threshold: Single organization IV threshold, values below this are considered not satisfied
72 max_org_threshold: Maximum tolerated organization count, if more than this number of organizations have IV below threshold, record in IV processing table
73
74 Returns:
75 data: Data after dropping
76 iv_detail: IV details (IV value of each feature in each organization and overall)
77 iv_process: IV processing table (features that do not meet the conditions)
78 """
79 from references.func import calculate_iv
80 from joblib import Parallel, delayed
81
82 orgs = sorted (data[ 'new_org' ].unique())
83
84 print ( f " IV calculation: feature count= { len (features) } , organization count= { len (orgs) } " )
85
86 # Calculate IV values for all organizations at once
87 def _calc_org_iv (org):
88 org_data = data[data[ 'new_org' ] == org]
89 org_iv = calculate_iv(org_data, features, n_jobs = 1 )
90 if len (org_iv) > 0 :
91 org_iv = org_iv.rename( columns = { 'IV' : 'IV值' })
92 org_iv[ '机构' ] = org
93 return org_iv
94 return None
95
96 # Calculate overall IV
97 print ( f " Calculating overall IV..." )
98 iv_overall = calculate_iv(data, features, n_jobs = n_jobs)
99 print ( f " Overall IV calculation result: { len (iv_overall) } features" )
100 if len (iv_overall) == 0 :
101 print ( f " Warning: Overall IV calculation result is empty, returning empty table" )
102 return data, pd.DataFrame( columns = [ '变量' , 'IV值' , '机构' , '类型' ]), pd.DataFrame( columns = [ '变量' , '整体IV' , '低IV机构数' , '处理原因' ])
103 iv_overall = iv_overall.rename( columns = { 'IV' : 'IV值' })
104
105 # Parallel calculation of IV values for all organizations
106 print ( f " Parallel calculation of IV values for { len (orgs) } organizations..." )
107 iv_by_org_results = Parallel( n_jobs = n_jobs, verbose = 0 )(
108 delayed(_calc_org_iv)(org) for org in orgs
109 )
110 iv_by_org = [r for r in iv_by_org_results if r is not None ]
111 iv_by_org = pd.concat(iv_by_org, ignore_index = True ) if iv_by_org else pd.DataFrame( columns = [ '变量' , 'IV值' , '机构' ])
112 print ( f " Organization IV summary: { len (iv_by_org) } records" )
113
114 # Convert to wide format: feature, overall, org1, org2, ..., orgn
115 iv_detail_dict = { '变量' : []}
116 iv_detail_dict[ '整体' ] = []
117
118 for org in orgs:
119 iv_detail_dict[org] = []
120
121 # Get all features
122 all_vars = set (iv_overall[ '变量' ].tolist())
123 if len (iv_by_org) > 0 :
124 all_vars.update(iv_by_org[ '变量' ].tolist())
125 all_vars = sorted (all_vars)
126
127 for var in all_vars:
128 iv_detail_dict[ '变量' ].append(var)
129
130 # Overall IV
131 var_overall = iv_overall[iv_overall[ '变量' ] == var]
132 if len (var_overall) > 0 :
133 iv_detail_dict[ '整体' ].append(var_overall[ 'IV值' ].values[ 0 ])
134 else :
135 iv_detail_dict[ '整体' ].append( None )
136
137 # IV for each organization
138 for org in orgs:
139 var_org = iv_by_org[iv_by_org[ '机构' ] == org]
140 var_org = var_org[var_org[ '变量' ] == var]
141 if len (var_org) > 0 :
142 iv_detail_dict[org].append(var_org[ 'IV值' ].values[ 0 ])
143 else :
144 iv_detail_dict[org].append( None )
145
146 iv_detail = pd.DataFrame(iv_detail_dict)
147 # Sort by overall IV in descending order
148 iv_detail = iv_detail.sort_values( '整体' , ascending = False )
149 iv_detail = iv_detail.reset_index( drop = True )
150
151 # Mark features that do not meet conditions
152 # 1. Overall IV below threshold
153 iv_overall_low = iv_overall[iv_overall[ 'IV值' ] < overall_iv_threshold][ '变量' ].tolist()
154
155 # 2. Number of organizations with single organization IV below threshold
156 if len (iv_by_org) > 0 :
157 iv_by_org_low = iv_by_org[iv_by_org[ 'IV值' ] < org_iv_threshold].groupby( '变量' ).size().reset_index()
158 iv_by_org_low.columns = [ '变量' , '低IV机构数' ]
159 else :
160 iv_by_org_low = pd.DataFrame( columns = [ '变量' , '低IV机构数' ])
161
162 # Get list of low IV organizations for each feature
163 low_iv_orgs_dict = {}
164 if len (iv_by_org) > 0 :
165 for var in iv_by_org[ '变量' ].unique():
166 var_orgs = iv_by_org[(iv_by_org[ '变量' ] == var) & (iv_by_org[ 'IV值' ] < org_iv_threshold)][ '机构' ].tolist()
167 low_iv_orgs_dict[var] = var_orgs
168
169 # 3. Mark features that need processing
170 iv_process = []
171
172 # Debug info: IV distribution statistics
173 if len (iv_overall) > 0 :
174 print ( f " Overall IV statistics: min= { iv_overall[ 'IV值' ].min() :.4f} , max= { iv_overall[ 'IV值' ].max() :.4f} , median= { iv_overall[ 'IV值' ].median() :.4f} " )
175 print ( f " Number of features with overall IV less than { overall_iv_threshold } : { (iv_overall[ 'IV值' ] < overall_iv_threshold).sum() } / { len (iv_overall) } " )
176
177 if len (iv_by_org_low) > 0 :
178 print ( f " Statistics of features with organization IV less than { org_iv_threshold } :" )
179 print ( f " Maximum low IV organization count: { iv_by_org_low[ '低IV机构数' ].max() } " )
180 print ( f " Number of features with low IV organization count greater than or equal to { max_org_threshold } : { (iv_by_org_low[ '低IV机构数' ] >= max_org_threshold).sum() } / { len (iv_by_org_low) } " )
181
182 for var in features:
183 reasons = []
184
185 # Check overall IV
186 var_overall_iv = iv_overall[iv_overall[ '变量' ] == var][ 'IV值' ].values
187 if len (var_overall_iv) > 0 and var_overall_iv[ 0 ] < overall_iv_threshold:
188 reasons.append( f 'overall IV { var_overall_iv[ 0 ] :.4f} less than threshold { overall_iv_threshold } ' )
189
190 # Check organization IV
191 var_org_low = iv_by_org_low[iv_by_org_low[ '变量' ] == var]
192 if len (var_org_low) > 0 and var_org_low[ '低IV机构数' ].values[ 0 ] >= max_org_threshold:
193 reasons.append( f 'IV less than threshold { org_iv_threshold } in { var_org_low[ "低IV机构数" ].values[ 0 ] } organizations' )
194
195 if reasons:
196 iv_process.append({
197 '变量' : var,
198 '处理原因' : '; ' .join(reasons),
199 '低IV机构' : ',' .join(low_iv_orgs_dict.get(var, []))
200 })
201
202 iv_process = pd.DataFrame(iv_process)
203 iv_process = iv_process.reset_index( drop = True )
204
205 # Drop features that do not meet conditions
206 if len (iv_process) > 0 and '变量' in iv_process.columns:
207 to_drop = iv_process[ '变量' ].tolist()
208 data = data.drop( columns = [c for c in to_drop if c in data.columns])
209
210 return data, iv_detail, iv_process
211
212
213 def drop_highcorr_features (data: pd.DataFrame, features: List[ str ],
214 threshold: float = 0.8 , gain_dict: dict = None , top_n_keep: int = 20 ) -> tuple :
215 """Drop high correlation features - based on original gain, drop one feature at a time
216
217 Args:
218 data: Data
219 features: Feature list
220 threshold: Correlation threshold
221 gain_dict: Mapping dictionary from feature to original gain
222 top_n_keep: Keep top N features by original gain ranking
223
224 Returns:
225 data: Data after dropping
226 dropped_info: Drop information
227 """
228 if gain_dict is None :
229 gain_dict = {}
230
231 # Get current feature list (only features that exist in data)
232 current_features = [f for f in features if f in data.columns]
233
234 if len (current_features) == 0 :
235 return data, pd.DataFrame( columns = [ '变量' , '相关变量' , '去除条件' ])
236
237 # Determine features to keep (top N by original gain)
238 if gain_dict:
239 # Only consider features that exist in current features
240 current_gain_dict = {k: v for k, v in gain_dict.items() if k in current_features}
241 if current_gain_dict:
242 sorted_features = sorted (current_gain_dict.keys(), key =lambda x: current_gain_dict[x], reverse = True )
243 top_features = set (sorted_features[:top_n_keep])
244 # Create mapping from feature to ranking
245 rank_dict = {v: i + 1 for i, v in enumerate (sorted_features)}
246 else :
247 top_features = set ()
248 rank_dict = {}
249 else :
250 top_features = set ()
251 rank_dict = {}
252
253 dropped_info = []
254
255 # Loop to drop until no high correlation feature pairs
256 while True :
257 # Recalculate correlation matrix (only for current remaining features)
258 current_features = [f for f in current_features if f in data.columns]
259 if len (current_features) < 2 :
260 break
261
262 corr = data[current_features].corr().abs()
263 upper = corr.where(np.triu(np.ones(corr.shape), k = 1 ).astype( bool ))
264
265 # Find all high correlation feature pairs
266 high_corr_pairs = []
267 for i, col1 in enumerate (upper.columns):
268 for col2 in upper.columns[i + 1 :]:
269 corr_val = upper.loc[col1, col2]
270 if pd.notna(corr_val) and corr_val > threshold:
271 high_corr_pairs.append((col1, col2, corr_val))
272
273 if not high_corr_pairs:
274 break
275
276 # For each high correlation feature pair, select the feature with smaller original gain as candidate for dropping
277 candidates = set ()
278 for col1, col2, corr_val in high_corr_pairs:
279 # Skip top N kept features
280 if col1 in top_features and col2 in top_features:
281 continue
282
283 gain1 = gain_dict.get(col1, 0 )
284 gain2 = gain_dict.get(col2, 0 )
285
286 # Select feature with smaller original gain
287 if gain1 <= gain2:
288 candidates.add(col1)
289 else :
290 candidates.add(col2)
291
292 if not candidates:
293 break
294
295 # Select feature with smallest original gain among candidates for dropping
296 candidates_list = list (candidates)
297 candidates_with_gain = [(c, gain_dict.get(c, 0 )) for c in candidates_list]
298 candidates_with_gain.sort( key =lambda x: x[ 1 ])
299 to_drop = candidates_with_gain[ 0 ][ 0 ]
300
301 # Find all features highly correlated with this feature
302 related_vars = []
303 for col1, col2, corr_val in high_corr_pairs:
304 if col1 == to_drop:
305 related_vars.append((col2, corr_val))
306 elif col2 == to_drop:
307 related_vars.append((col1, corr_val))
308
309 # Record drop information
310 # Related variables column: show feature name and similarity value (correlation value)
311 related_str = ',' .join([ f " { v } (similarity= { c :.4f} )" for v, c in related_vars])
312 # Removal condition column: show related features and their corresponding gain values
313 gain_str = ',' .join([ f " { v } (gain= { gain_dict.get(v, 0 ) :.2f} )" for v, c in related_vars])
314 dropped_info.append({
315 '变量' : to_drop,
316 '原始gain' : gain_dict.get(to_drop, 0 ),
317 '原始gain排名' : rank_dict.get(to_drop, '-' ),
318 '相关变量' : related_str,
319 '去除条件' : gain_str
320 })
321
322 # Delete this feature from data
323 data = data.drop( columns = [to_drop], errors = 'ignore' )
324 current_features.remove(to_drop)
325
326 print ( f " Dropped feature: { to_drop } (original gain= { gain_dict.get(to_drop, 0 ) :.2f} )" )
327
328 # Convert to DataFrame and sort by original gain in descending order
329 dropped_df = pd.DataFrame(dropped_info)
330 if len (dropped_df) > 0 :
331 dropped_df = dropped_df.sort_values( '原始gain' , ascending = False )
332 dropped_df = dropped_df.reset_index( drop = True )
333
334 return data, dropped_df
335
336
337 def drop_highnoise_features (data: pd.DataFrame, features: List[ str ],
338 n_estimators: int = 100 , max_depth: int = 5 , gain_threshold: float = 50 ) -> tuple :
339 """Null Importance to remove high noise features"""
340 # Check if feature list is empty
341 if len (features) == 0 :
342 print ( " No features to process" )
343 return data, pd.DataFrame( columns = [ '变量' , '原始gain' , '反转后gain' ])
344
345 # Check if data is sufficient
346 if len (data) < 1000 :
347 print ( f " Insufficient data ( { len (data) } rows), skip Null Importance" )
348 return data, pd.DataFrame( columns = [ '变量' , '原始gain' , '反转后gain' ])
349
350 X = data[features].copy()
351 Y = data[ 'new_target' ].copy()
352
353 # Check if X is empty or contains NaN
354 if X.shape[ 1 ] == 0 :
355 print ( " Feature data is empty, skip Null Importance" )
356 return data, pd.DataFrame( columns = [ '变量' , '原始gain' , '反转后gain' ])
357
358 # Fill NaN
359 X = X.fillna( 0 )
360
361 # Shuffle labels
362 Y_permuted = Y.copy()
363 for _ in range ( 20 ):
364 Y_permuted = np.random.permutation(Y_permuted)
365
366 clf = lgb.LGBMClassifier(
367 objective = 'binary' , boosting_type = 'gbdt' , learning_rate = 0.05 ,
368 max_depth = max_depth, min_child_samples = 2000 , min_child_weight = 20 ,
369 n_estimators = n_estimators, num_leaves = 2 ** max_depth - 1 , n_jobs =- 1 , verbose =- 1
370 )
371
372 clf_permuted = lgb.LGBMClassifier(
373 objective = 'binary' , boosting_type = 'gbdt' , learning_rate = 0.05 ,
374 max_depth = max_depth, min_child_samples = 2000 , min_child_weight = 20 ,
375 n_estimators = n_estimators, num_leaves = 2 ** max_depth - 1 , n_jobs =- 1 , verbose =- 1
376 )
377
378 results, results_permuted = [], []
379
380 print ( "Null Importance calculation in progress..." )
381 for i in range ( 2 ):
382 random_n = np.random.randint( 30 )
383
384 X_train, X_test, y_train, y_test = train_test_split(X, Y, test_size = 0.3 , random_state = random_n)
385
386 # Check if training data is valid
387 if X_train.shape[ 0 ] == 0 or X_test.shape[ 0 ] == 0 :
388 print ( f " Round { i + 1 } : Data split failed, skip" )
389 continue
390
391 clf.fit(X_train, y_train)
392
393 X_train_, X_test_, y_train_, y_test_ = train_test_split(X, Y_permuted, test_size = 0.3 , random_state = random_n)
394
395 if X_train_.shape[ 0 ] == 0 or X_test_.shape[ 0 ] == 0 :
396 print ( f " Round { i + 1 } : Shuffled data split failed, skip" )
397 continue
398
399 clf_permuted.fit(X_train_, y_train_)
400
401 imp_real = pd.DataFrame({
402 'feature' : clf.booster_.feature_name(),
403 'gain' : clf.booster_.feature_importance( importance_type = 'gain' )
404 })
405 imp_perm = pd.DataFrame({
406 'feature' : clf_permuted.booster_.feature_name(),
407 'gain' : clf_permuted.booster_.feature_importance( importance_type = 'gain' )
408 })
409
410 results.append(imp_real)
411 results_permuted.append(imp_perm)
412
413 train_auc = roc_auc_score(y_train, clf.predict_proba(X_train)[:, 1 ])
414 test_auc = roc_auc_score(y_test, clf.predict_proba(X_test)[:, 1 ])
415 print ( f " Round { i + 1 } : train_auc= { train_auc :.3f} , test_auc= { test_auc :.3f} " )
416
417 # Check if there are valid results
418 if len (results) == 0 or len (results_permuted) == 0 :
419 print ( " No valid training results, skip Null Importance" )
420 return data, pd.DataFrame( columns = [ '变量' , '原始gain' , '反转后gain' ])
421
422 imp_real_avg = pd.concat(results).groupby( 'feature' )[ 'gain' ].mean().reset_index()
423 imp_perm_avg = pd.concat(results_permuted).groupby( 'feature' )[ 'gain' ].mean().reset_index()
424
425 comparison = imp_real_avg.merge(imp_perm_avg, on = 'feature' , suffixes = ( '_real' , '_perm' ))
426 comparison[ 'gain_real' ] = comparison[ 'gain_real' ].fillna( 0 )
427 comparison[ 'gain_perm' ] = comparison[ 'gain_perm' ].fillna( 0 )
428
429 # Use condition where absolute difference of gain values before and after permutation is less than 50
430 comparison[ 'gain_diff' ] = (comparison[ 'gain_real' ] - comparison[ 'gain_perm' ]).abs()
431 noise_features = comparison[comparison[ 'gain_diff' ] < gain_threshold][ 'feature' ].tolist()
432
433 # List original gain and permuted gain for all features
434 dropped_info = pd.DataFrame({
435 '变量' : comparison[ 'feature' ].values,
436 '原始gain' : comparison[ 'gain_real' ].values,
437 '反转后gain' : comparison[ 'gain_perm' ].values
438 })
439 # Add status column, mark dropped features as '去除', kept features as '保留'
440 dropped_info[ '状态' ] = dropped_info.apply(
441 lambda x: '去除' if np.abs(x[ '原始gain' ] - x[ '反转后gain' ]) < gain_threshold else '保留' , axis = 1
442 )
443 # Sort by original gain in descending order
444 dropped_info = dropped_info.sort_values( '原始gain' , ascending = False )
445 dropped_info = dropped_info.reset_index( drop = True )
446 # Add original gain ranking column
447 dropped_info[ '原始gain排名' ] = range ( 1 , len (dropped_info) + 1 )
448
449 data = data.drop( columns = [c for c in noise_features if c in data.columns])
450
451 print ( f " Dropped { len (noise_features) } noise features" )
452 return data, dropped_info
453
454
455 def _calc_single_psi (args):
456 """Calculate PSI for a single organization and single feature - NaN as separate bin"""
457 org, train_month, test_month, train_n, test_n, f, data_ref, min_sample = args
458
459 try :
460 org_data = data_ref[data_ref[ 'new_org' ] == org]
461 train_data = org_data[org_data[ 'new_date_ym' ] == train_month]
462 test_data = org_data[org_data[ 'new_date_ym' ] == test_month]
463
464 # Get data
465 train_vals = train_data[f].values
466 test_vals = test_data[f].values
467
468 # Mark NaN
469 train_nan_mask = pd.isna(train_vals)
470 test_nan_mask = pd.isna(test_vals)
471
472 # Non-NaN values for binning
473 train_nonan = train_vals[ ~ train_nan_mask]
474 test_nonan = test_vals[ ~ test_nan_mask]
475
476 if len (train_nonan) < min_sample or len (test_nonan) < min_sample:
477 return {
478 '机构' : org, '日期' : f " { train_month } -> { test_month } " ,
479 '变量' : f, 'PSI' : None , '有效计算' : 0 ,
480 '样本数' : train_n
481 }
482
483 # Bin based on non-NaN data (10 bins)
484 try :
485 bins = pd.qcut(train_nonan, q = 10 , duplicates = 'drop' , retbins = True )[ 1 ]
486 except Exception :
487 bins = pd.cut(train_nonan, bins = 10 , retbins = True )[ 1 ]
488
489 # Calculate proportion of each bin (including NaN bin)
490 train_counts = []
491 test_counts = []
492
493 for i in range ( len (bins)):
494 if i == 0 :
495 train_counts.append(( ~ train_nan_mask & (train_vals <= bins[i])).sum())
496 test_counts.append(( ~ test_nan_mask & (test_vals <= bins[i])).sum())
497 else :
498 train_counts.append(( ~ train_nan_mask & (train_vals > bins[i - 1 ]) & (train_vals <= bins[i])).sum())
499 test_counts.append(( ~ test_nan_mask & (test_vals > bins[i - 1 ]) & (test_vals <= bins[i])).sum())
500
501 # NaN bin
502 train_counts.append(train_nan_mask.sum())
503 test_counts.append(test_nan_mask.sum())
504
505 # Convert to proportions
506 train_pct = np.array(train_counts) / len (train_vals)
507 test_pct = np.array(test_counts) / len (test_vals)
508
509 # Avoid 0 values
510 train_pct = np.where(train_pct == 0 , 1e-6 , train_pct)
511 test_pct = np.where(test_pct == 0 , 1e-6 , test_pct)
512
513 # Calculate PSI
514 psi = np.sum((test_pct - train_pct) * np.log(test_pct / train_pct))
515
516 return {
517 '机构' : org, '日期' : f " { train_month } -> { test_month } " ,
518 '变量' : f, 'PSI' : round (psi, 4 ), '有效计算' : 1 ,
519 '样本数' : train_n
520 }
521 except Exception as e:
522 return {
523 '机构' : org, '日期' : f " { train_month } -> { test_month } " ,
524 '变量' : f, 'PSI' : None , '有效计算' : 0 ,
525 '样本数' : train_n
526 }
527
528
529 def drop_highpsi_features (data: pd.DataFrame, features: List[ str ],
530 psi_threshold: float = 0.1 , max_months_ratio: float = 1 / 3 ,
531 max_orgs: int = 4 , min_sample_per_month: int = 100 , n_jobs: int = 4 ) -> tuple :
532 """Drop high PSI features - by organization + month-by-month version
533
534 Multi-processing at feature level, loop through organizations, parallel calculation of features within organizations
535
536 Args:
537 psi_threshold: PSI threshold, values above this are considered unstable
538 max_months_ratio: Maximum tolerated month ratio, if more than this ratio of months have PSI above threshold, record in processing table
539 max_orgs: Maximum tolerated organization count, if more than this number of organizations are unstable, record in processing table
540 min_sample_per_month: Minimum sample count per month
541
542 Returns:
543 data: Data after dropping
544 psi_detail: PSI details (PSI value of each feature in each organization each month)
545 psi_process: PSI processing table (features that do not meet the conditions)
546 """
547 orgs = data[ 'new_org' ].unique()
548
549 # Build task list: each organization, each pair of months, each feature
550 tasks = []
551 for org in orgs:
552 org_data = data[data[ 'new_org' ] == org]
553 months = sorted (org_data[ 'new_date_ym' ].unique())
554
555 if len (months) < 2 :
556 continue
557
558 for i in range ( len (months) - 1 ):
559 train_month = months[i]
560 test_month = months[i + 1 ]
561
562 train_data = org_data[org_data[ 'new_date_ym' ] == train_month]
563 test_data = org_data[org_data[ 'new_date_ym' ] == test_month]
564
565 train_n = len (train_data)
566 test_n = len (test_data)
567
568 for f in features:
569 tasks.append((org, train_month, test_month, train_n, test_n, f, data, min_sample_per_month))
570
571 # Multi-process PSI calculation (parallel at feature level)
572 print ( f " PSI calculation: { len (tasks) } tasks, using { n_jobs } processes" )
573 results = Parallel( n_jobs = n_jobs, verbose = 0 )(delayed(_calc_single_psi)(task) for task in tasks)
574
575 psi_df = pd.DataFrame(results)
576
577 if len (psi_df) == 0 :
578 return data, pd.DataFrame( columns = [ '变量' , '机构' , '月份' , 'PSI值' ]), pd.DataFrame( columns = [ '变量' , '处理原因' ])
579
580 # Filter valid calculation records
581 valid_psi = psi_df[psi_df[ '有效计算' ] == 1 ].copy()
582
583 if len (valid_psi) == 0 :
584 return data, pd.DataFrame( columns = [ '变量' , '机构' , '月份' , 'PSI值' ]), pd.DataFrame( columns = [ '变量' , '处理原因' ])
585
586 # PSI detail table: PSI value of each feature in each organization each month
587 # Change date to single month, initial month PSI value is 0
588 psi_detail = valid_psi[[ '机构' , '日期' , '变量' , 'PSI' ]].copy()
589
590 # Parse date, extract test month
591 psi_detail[ '月份' ] = psi_detail[ '日期' ].apply( lambda x: x.split( '->' )[ 1 ] if '->' in x else x)
592 psi_detail = psi_detail.rename( columns = { 'PSI' : 'PSI值' })
593
594 # Sort by feature, organization, month in ascending order
595 psi_detail = psi_detail.sort_values([ '变量' , '机构' , '月份' ], ascending = [ True , True , True ])
596
597 # Get all organizations and months
598 all_orgs = sorted (psi_detail[ '机构' ].unique())
599 all_vars = sorted (psi_detail[ '变量' ].unique())
600
601 # Build complete PSI detail table (including initial month, PSI value is 0)
602 psi_detail_list = []
603 for org in all_orgs:
604 org_data = psi_detail[psi_detail[ '机构' ] == org]
605 if len (org_data) == 0 :
606 continue
607
608 # Get all months for this organization
609 months = sorted (org_data[ '月份' ].unique())
610
611 for var in all_vars:
612 var_data = org_data[org_data[ '变量' ] == var]
613 if len (var_data) == 0 :
614 continue
615
616 # Initial month PSI value is 0
617 psi_detail_list.append({
618 '机构' : org,
619 '变量' : var,
620 '月份' : months[ 0 ],
621 'PSI值' : 0.0
622 })
623
624 # Subsequent months PSI values are calculation results
625 for i in range ( 1 , len (months)):
626 month = months[i]
627 var_month_data = var_data[var_data[ '月份' ] == month]
628 if len (var_month_data) > 0 :
629 psi_value = var_month_data[ 'PSI值' ].values[ 0 ]
630 else :
631 psi_value = 0.0
632 psi_detail_list.append({
633 '机构' : org,
634 '变量' : var,
635 '月份' : month,
636 'PSI值' : psi_value
637 })
638
639 psi_detail = pd.DataFrame(psi_detail_list)
640 psi_detail = psi_detail[[ '机构' , '变量' , '月份' , 'PSI值' ]]
641 psi_detail = psi_detail.reset_index( drop = True )
642 # Sort by feature, organization, month in ascending order
643 psi_detail = psi_detail.sort_values([ '变量' , '机构' , '月份' ], ascending = [ True , True , True ])
644 psi_detail = psi_detail.reset_index( drop = True )
645
646 # Mark unstable
647 valid_psi[ '不稳定' ] = (valid_psi[ 'PSI' ] > psi_threshold).astype( int )
648
649 # Summary: number of unstable months and total months for each organization each feature
650 org_summary = valid_psi.groupby([ '机构' , '变量' ]).agg(
651 不稳定月份数 = ( '不稳定' , 'sum' ),
652 总月份数 = ( '变量' , 'count' )
653 ).reset_index()
654
655 # Mark whether each organization each feature is unstable
656 # Ensure threshold is at least 1, avoid being too strict when organization has few months
657 org_summary[ '不稳定阈值' ] = org_summary[ '总月份数' ].apply(
658 lambda x: max ( 1 , int (x * max_months_ratio))
659 )
660 org_summary[ '是否不稳定' ] = org_summary[ '不稳定月份数' ] >= org_summary[ '不稳定阈值' ]
661
662 # Organization level summary: number of unstable organizations
663 org_count = len (orgs)
664 channel_summary = org_summary.groupby( '变量' ).apply(
665 lambda x: pd.Series({
666 '机构数' : org_count,
667 '不稳定机构数' : x[ '是否不稳定' ].sum()
668 })
669 ).reset_index()
670
671 # Mark features that need processing
672 channel_summary[ '需处理' ] = channel_summary[ '不稳定机构数' ] >= max_orgs
673 channel_summary[ '处理原因' ] = channel_summary.apply(
674 lambda x: f 'PSI unstable in { x[ "不稳定机构数" ] } organizations' if x[ '需处理' ] else '' , axis = 1
675 )
676
677 # Get list of unstable organizations for each feature
678 unstable_orgs_dict = {}
679 for var in org_summary[ '变量' ].unique():
680 var_orgs = org_summary[(org_summary[ '变量' ] == var) & (org_summary[ '是否不稳定' ] == True )][ '机构' ].tolist()
681 unstable_orgs_dict[var] = var_orgs
682
683 # PSI processing table: features that do not meet the conditions
684 psi_process = channel_summary[channel_summary[ '需处理' ]].copy()
685 psi_process[ '不稳定机构' ] = psi_process[ '变量' ].apply( lambda x: ',' .join(unstable_orgs_dict.get(x, [])))
686 psi_process = psi_process[[ '变量' , '处理原因' , '不稳定机构' ]]
687 psi_process = psi_process.reset_index( drop = True )
688
689 # Filter features to drop
690 if len (psi_process) > 0 and '变量' in psi_process.columns:
691 to_drop_vars = psi_process[ '变量' ].tolist()
692 data = data.drop( columns = [c for c in to_drop_vars if c in data.columns])
693
694 return data, psi_detail, psi_process
695
696
697 def iv_distribution_by_org (iv_detail: pd.DataFrame, oos_orgs: list = None , iv_bins: list = [ 0 , 0.02 , 0.05 , 0.1 , float ( 'inf' )]) -> pd.DataFrame:
698 """Count number and proportion of features in different IV ranges for each organization
699
700 Args:
701 iv_detail: IV detail table (containing feature, overall, organization columns)
702 oos_orgs: Out-of-sample organization list
703 iv_bins: IV range boundaries [0, 0.02, 0.05, 0.1, inf]
704
705 Returns:
706 IV distribution statistics table
707 """
708 if oos_orgs is None :
709 oos_orgs = []
710
711 # Get organization columns (exclude '变量' and '整体' columns)
712 org_cols = [c for c in iv_detail.columns if c not in [ '变量' , '整体' ]]
713
714 # Define range labels
715 bin_labels = [ '[0, 0.02)' , '[0.02, 0.05)' , '[0.05, 0.1)' , '[0.1, +∞)' ]
716
717 result = []
718
719 # Statistics for each organization (not including overall)
720 for org in org_cols:
721 org_iv = iv_detail[org].dropna()
722 total_vars = len (org_iv)
723
724 # Determine organization type
725 org_type = '贷外' if org in oos_orgs else '建模'
726
727 for i in range ( len (iv_bins) - 1 ):
728 lower = iv_bins[i]
729 upper = iv_bins[i + 1 ]
730 if upper == float ( 'inf' ):
731 count = ((org_iv >= lower)).sum()
732 else :
733 count = ((org_iv >= lower) & (org_iv < upper)).sum()
734 ratio = count / total_vars if total_vars > 0 else 0
735 result.append({
736 '机构' : org,
737 '类型' : org_type,
738 'IV区间' : bin_labels[i],
739 '变量个数' : count,
740 '占比' : f ' { ratio :.2%} '
741 })
742
743 return pd.DataFrame(result)
744
745
746 def psi_distribution_by_org (psi_detail: pd.DataFrame, oos_orgs: list = None , psi_bins: list = [ 0 , 0.05 , 0.1 , float ( 'inf' )]) -> pd.DataFrame:
747 """Count number and proportion of features in different PSI ranges for each organization
748
749 Args:
750 psi_detail: PSI detail table (containing organization, feature, month, PSI value columns)
751 oos_orgs: Out-of-sample organization list
752 psi_bins: PSI range boundaries [0, 0.05, 0.1, inf]
753
754 Returns:
755 PSI distribution statistics table
756 """
757 if oos_orgs is None :
758 oos_orgs = []
759
760 # Define range labels
761 bin_labels = [ '[0, 0.05)' , '[0.05, 0.1)' , '[0.1, +∞)' ]
762
763 result = []
764
765 # Get all organizations
766 orgs = psi_detail[ '机构' ].unique()
767
768 for org in orgs:
769 org_data = psi_detail[psi_detail[ '机构' ] == org]
770
771 # Determine organization type
772 org_type = '贷外' if org in oos_orgs else '建模'
773
774 # For each feature, take its maximum PSI value
775 var_max_psi = org_data.groupby( '变量' )[ 'PSI值' ].max()
776 total_vars = len (var_max_psi)
777
778 for i in range ( len (psi_bins) - 1 ):
779 lower = psi_bins[i]
780 upper = psi_bins[i + 1 ]
781 if upper == float ( 'inf' ):
782 count = ((var_max_psi >= lower)).sum()
783 else :
784 count = ((var_max_psi >= lower) & (var_max_psi < upper)).sum()
785 ratio = count / total_vars if total_vars > 0 else 0
786 result.append({
787 '机构' : org,
788 '类型' : org_type,
789 'PSI区间' : bin_labels[i],
790 '变量个数' : count,
791 '占比' : f ' { ratio :.2%} '
792 })
793
794 return pd.DataFrame(result)
795
796
797 def value_ratio_distribution_by_org (data: pd.DataFrame, features: List[ str ],
798 oos_orgs: list = None ,
799 value_bins: list = [ 0 , 0.15 , 0.35 , 0.65 , 0.95 , 1.0 ]) -> pd.DataFrame:
800 """Count number and proportion of features in different value ratio ranges for each organization
801
802 Args:
803 data: Data (containing new_org column)
804 features: Feature list
805 oos_orgs: Out-of-sample organization list
806 value_bins: Value ratio range boundaries [0, 0.15, 0.35, 0.65, 0.95, 1.0]
807
808 Returns:
809 Value ratio distribution statistics table
810 """
811 if oos_orgs is None :
812 oos_orgs = []
813
814 # Define range labels
815 bin_labels = [ '[0, 15%)' , '[15%, 35%)' , '[35%, 65%)' , '[65%, 95%)' , '[95%, 100%]' ]
816
817 result = []
818
819 # Get all organizations
820 orgs = data[ 'new_org' ].unique()
821
822 for org in orgs:
823 org_data = data[data[ 'new_org' ] == org]
824
825 # Determine organization type
826 org_type = '贷外' if org in oos_orgs else '建模'
827
828 # Calculate value ratio for each feature (proportion of non-NaN)
829 value_ratios = {}
830 for f in features:
831 if f in org_data.columns:
832 non_null_count = org_data[f].notna().sum()
833 total_count = len (org_data)
834 value_ratios[f] = non_null_count / total_count if total_count > 0 else 0
835
836 # Count number of features in each range
837 total_vars = len (value_ratios)
838 for i in range ( len (value_bins) - 1 ):
839 lower = value_bins[i]
840 upper = value_bins[i + 1 ]
841 if upper == 1.0 :
842 count = sum ( 1 for v in value_ratios.values() if lower <= v <= upper)
843 else :
844 count = sum ( 1 for v in value_ratios.values() if lower <= v < upper)
845 ratio = count / total_vars if total_vars > 0 else 0
846 result.append({
847 '机构' : org,
848 '类型' : org_type,
849 '有值率区间' : bin_labels[i],
850 '变量个数' : count,
851 '占比' : f ' { ratio :.2%} '
852 })
853
854 return pd.DataFrame(result)
855
856
857 def calculate_iv_by_org (data: pd.DataFrame, features: List[ str ],
858 n_jobs: int = 4 ) -> Tuple[pd.DataFrame, pd.DataFrame]:
859 """Calculate IV by organization and overall
860
861 Returns:
862 iv_by_org: IV details by organization
863 iv_overall: Overall IV
864 """
865 from references.func import calculate_iv
866
867 orgs = data[ 'new_org' ].unique()
868
869 # Overall IV
870 iv_overall = calculate_iv(data, features, n_jobs = n_jobs)
871 iv_overall[ '类型' ] = '整体'
872
873 # IV by organization
874 iv_by_org = []
875 for org in orgs:
876 org_data = data[data[ 'new_org' ] == org]
877 org_iv = calculate_iv(org_data, features, n_jobs = 1 ) # Single process for single organization
878 if len (org_iv) > 0 : # Only add non-empty results
879 org_iv[ '机构' ] = org
880 org_iv[ '类型' ] = '分机构'
881 iv_by_org.append(org_iv)
882
883 iv_by_org = pd.concat(iv_by_org, ignore_index = True ) if iv_by_org else pd.DataFrame( columns = [ '变量' , 'IV' , '机构' , '类型' ])
884
885 return iv_by_org, iv_overall
886
887
888 def calculate_psi_detail (data: pd.DataFrame, features: List[ str ],
889 max_psi: float = 0.1 , min_months_unstable: int = 3 ,
890 min_sample: int = 100 , n_jobs: int = 4 ) -> tuple :
891 """Calculate month-by-month PSI details for each feature in each organization, and mark whether to drop
892
893 Returns:
894 data: Data after dropping
895 dropped: Summary of dropped features
896 psi_summary: Complete PSI details (including drop flag)
897 """
898 orgs = data[ 'new_org' ].unique()
899
900 # Build tasks
901 tasks = []
902 for org in orgs:
903 org_data = data[data[ 'new_org' ] == org]
904 months = sorted (org_data[ 'new_date_ym' ].unique())
905
906 if len (months) < 2 :
907 continue
908
909 for i in range ( len (months) - 1 ):
910 train_month = months[i]
911 test_month = months[i + 1 ]
912
913 train_data = org_data[org_data[ 'new_date_ym' ] == train_month]
914 test_data = org_data[org_data[ 'new_date_ym' ] == test_month]
915
916 train_n = len (train_data)
917 test_n = len (test_data)
918
919 for f in features:
920 tasks.append((org, train_month, test_month, train_n, test_n, f, data, min_sample))
921
922 # Multi-process calculation
923 print ( f " PSI calculation: { len (tasks) } tasks, using { n_jobs } processes" )
924 results = Parallel( n_jobs = n_jobs, verbose = 0 )(delayed(_calc_single_psi)(task) for task in tasks)
925
926 psi_df = pd.DataFrame(results)
927
928 if len (psi_df) == 0 :
929 return data, pd.DataFrame( columns = [ '变量' , '机构数' , '不稳定机构数' , '原因' ]), pd.DataFrame( columns = [ '变量' , '机构数' , '不稳定机构数' , '是否剔除' , '去除条件' ])
930
931 # Filter valid calculation records
932 valid_psi = psi_df[psi_df[ '有效计算' ] == 1 ].copy()
933
934 if len (valid_psi) == 0 :
935 return data, pd.DataFrame( columns = [ '变量' , '机构数' , '不稳定机构数' , '原因' ]), pd.DataFrame( columns = [ '变量' , '机构数' , '不稳定机构数' , '是否剔除' , '去除条件' ])
936
937 # Mark unstable
938 valid_psi[ '不稳定' ] = (valid_psi[ 'PSI' ] > max_psi).astype( int )
939
940 # Summary: number of unstable months for each organization each feature
941 org_summary = valid_psi.groupby([ '机构' , '变量' ])[ '不稳定' ].sum().reset_index()
942 org_summary.columns = [ '机构' , '变量' , '不稳定月份数' ]
943
944 # Organization level summary: features with more than min_months_unstable unstable months
945 org_count = len (orgs)
946 channel_summary = org_summary.groupby( '变量' ).apply(
947 lambda x: pd.Series({
948 '机构数' : org_count,
949 '不稳定机构数' : (x[ '不稳定月份数' ] >= min_months_unstable).sum()
950 })
951 ).reset_index()
952
953 # Mark features that need to be dropped (more than 1/3 organizations unstable)
954 channel_summary[ '需剔除' ] = channel_summary[ '不稳定机构数' ] > (channel_summary[ '机构数' ] / 3 )
955 channel_summary[ '是否剔除' ] = channel_summary[ '需剔除' ].astype( int )
956 channel_summary[ '去除条件' ] = channel_summary.apply(
957 lambda x: f 'More than 1/3 of { org_count } organizations have PSI> { max_psi } for { min_months_unstable } consecutive months' if x[ '需剔除' ] else '' , axis = 1
958 )
959
960 # Filter features to drop
961 if len (channel_summary) > 0 and '变量' in channel_summary.columns:
962 to_drop_vars = channel_summary[channel_summary[ '需剔除' ]][ '变量' ].tolist()
963 data = data.drop( columns = [c for c in to_drop_vars if c in data.columns])
964
965 # Organize drop information (only return dropped features)
966 dropped = channel_summary[channel_summary[ '需剔除' ]].copy()
967 dropped[ '原因' ] = f 'More than 1/3 of { org_count } organizations have PSI> { max_psi } for { min_months_unstable } consecutive months'
968
969 return data, dropped[[ '变量' , '机构数' , '不稳定机构数' , '原因' ]], channel_summary[[ '变量' , '机构数' , '不稳定机构数' , '是否剔除' , '去除条件' ]]
970
971
972 def export_cleaning_report (filepath: str , steps: list ,
973 iv_detail: pd.DataFrame = None ,
974 iv_process: pd.DataFrame = None ,
975 psi_detail: pd.DataFrame = None ,
976 psi_process: pd.DataFrame = None ,
977 params: dict = None ,
978 iv_distribution: pd.DataFrame = None ,
979 psi_distribution: pd.DataFrame = None ,
980 value_ratio_distribution: pd.DataFrame = None ):
981 """Export cleaning report to xlsx - one sheet per step
982
983 Args:
984 filepath: Output path
985 steps: Cleaning step list [(step name, DataFrame), ...]
986 iv_detail: IV details (IV value of each feature in each organization and overall)
987 iv_process: IV processing table (features that do not meet the conditions)
988 psi_detail: PSI details (PSI value of each feature in each organization each month)
989 psi_process: PSI processing table (features that do not meet the conditions)
990 params: Hyperparameter dictionary, used to dynamically generate conditions
991 iv_distribution: IV distribution statistics table
992 psi_distribution: PSI distribution statistics table
993 value_ratio_distribution: Value ratio distribution statistics table
994 """
995 from openpyxl import load_workbook
996
997 try :
998 wb = load_workbook(filepath)
999 except Exception :
1000 wb = Workbook()
1001 wb.remove(wb.active)
1002
1003 # Summary sheet - only show real filtering steps
1004 if '汇总' in wb.sheetnames:
1005 del wb[ '汇总' ]
1006 ws = wb.create_sheet( '汇总' , 0 )
1007 ws[ 'A1' ] = 'Data Cleaning Report'
1008 ws[ 'A2' ] = f 'Generated at: { datetime.now().strftime( "%Y-%m- %d %H:%M:%S" ) } '
1009 ws[ 'A4' ] = 'Step'
1010 ws[ 'B4' ] = 'Operation Details'
1011 ws[ 'C4' ] = 'Operation Result'
1012 ws[ 'D4' ] = 'Condition'
1013
1014 # Only show real filtering steps (excluding details and distribution statistics)
1015 filter_steps = [
1016 'Step4-异常月份处理' , 'Step6-高缺失率处理' , 'Step7-IV处理' ,
1017 'Step8-PSI处理' , 'Step9-null importance处理' , 'Step10-高相关性剔除'
1018 ]
1019
1020 # Steps to exclude (details and distribution statistics)
1021 exclude_steps = [
1022 'Step7-IV明细' , 'Step7-IV分布统计' , 'Step8-PSI明细' ,
1023 'Step8-PSI分布统计' , 'Step5-有值率分布统计'
1024 ]
1025
1026 # Steps that need to show drop count
1027 show_drop_count_steps = [ '分离OOS数据' ]
1028
1029 # Steps that only show parameter standards (no operation result)
1030 show_param_only_steps = [ '机构样本统计' , '缺失率明细' ]
1031
1032 # Add note: each step is executed independently
1033 ws[ 'A3' ] = 'Note: Each filtering step is executed independently, data is not deleted, only statistics of features that do not meet conditions are recorded'
1034
1035 # Get parameters, use default values if not provided
1036 if params is None :
1037 params = {}
1038
1039 min_ym_bad_sample = params.get( 'min_ym_bad_sample' , 10 )
1040 min_ym_sample = params.get( 'min_ym_sample' , 500 )
1041 missing_ratio = params.get( 'missing_ratio' , 0.6 )
1042 overall_iv_threshold = params.get( 'overall_iv_threshold' , 0.1 )
1043 org_iv_threshold = params.get( 'org_iv_threshold' , 0.1 )
1044 max_org_threshold = params.get( 'max_org_threshold' , 2 )
1045 psi_threshold = params.get( 'psi_threshold' , 0.1 )
1046 max_months_ratio = params.get( 'max_months_ratio' , 1 / 3 )
1047 max_orgs = params.get( 'max_orgs' , 4 )
1048 gain_threshold = params.get( 'gain_threshold' , 50 )
1049
1050 step_num = 1
1051 for name, df in steps:
1052 # Skip detail and distribution statistics steps
1053 if name in exclude_steps:
1054 continue
1055
1056 # Remove StepX- prefix from operation details
1057 display_name = name.replace( 'Step4-' , '' ).replace( 'Step6-' , '' ).replace( 'Step7-' , '' ).replace( 'Step8-' , '' ).replace( 'Step9-' , '' ).replace( 'Step10-' , '' )
1058
1059 # Only show parameter standard steps (no operation result)
1060 if name in show_param_only_steps:
1061 ws.cell( 4 + step_num, 1 , step_num)
1062 ws.cell( 4 + step_num, 2 , display_name)
1063 result = ''
1064 # Condition: show parameter standards
1065 if name == '机构样本统计' :
1066 condition = 'Statistics of sample count and bad sample rate for each organization'
1067 elif name == '缺失率明细' :
1068 condition = 'Calculate missing rate for each feature'
1069 else :
1070 condition = ''
1071 ws.cell( 4 + step_num, 3 , result)
1072 ws.cell( 4 + step_num, 4 , condition)
1073 step_num += 1
1074 # Show steps that need to display drop count
1075 elif name in show_drop_count_steps:
1076 ws.cell( 4 + step_num, 1 , step_num)
1077 ws.cell( 4 + step_num, 2 , display_name)
1078 if df is not None and len (df) > 0 :
1079 if name == '分离OOS数据' :
1080 # Special handling: show OOS and modeling sample counts
1081 if '变量' in df.columns and '数量' in df.columns:
1082
1083 oos_count = df[df[ '变量' ] == 'OOS样本' ][ '数量' ].values[ 0 ] if len (df[df[ '变量' ] == 'OOS样本' ]) > 0 else 0
1084 model_count = df[df[ '变量' ] == '建模样本' ][ '数量' ].values[ 0 ] if len (df[df[ '变量' ] == '建模样本' ]) > 0 else 0
1085 result = f 'OOS samples { oos_count } , modeling samples { model_count } '
1086 else :
1087 result = f ' { len (df) } rows'
1088 elif '变量' in df.columns:
1089 result = f 'Dropped { len (df) } features'
1090 else :
1091 result = f 'Dropped { len (df) } '
1092 condition = ''
1093 else :
1094 result = 'Empty'
1095 condition = ''
1096 ws.cell( 4 + step_num, 3 , result)
1097 ws.cell( 4 + step_num, 4 , condition)
1098 step_num += 1
1099 elif name in filter_steps:
1100 ws.cell( 4 + step_num, 1 , step_num)
1101 ws.cell( 4 + step_num, 2 , display_name)
1102
1103 # Generate operation result and condition
1104 if df is not None and len (df) > 0 :
1105 if name == 'Step4-异常月份处理' :
1106 # Operation result: dropped months
1107 if '年月' in df.columns:
1108 result = 'Dropped ' + ',' .join(df[ '年月' ].astype( str ).tolist())
1109 else :
1110 result = 'Dropped ' + ',' .join(df.iloc[:, 0 ].astype( str ).tolist())
1111 # Condition: parameter standards
1112 condition = f 'Months with bad sample count less than { min_ym_bad_sample } or total sample count less than { min_ym_sample } will be dropped (independent execution)'
1113 elif name == 'Step6-高缺失率处理' :
1114 # Operation result: number of dropped features
1115 if '变量' in df.columns:
1116 result = f 'Dropped { len (df) } features'
1117 else :
1118 result = f 'Dropped { len (df) } '
1119 # Condition: parameter standards
1120 condition = f 'Features with overall missing rate greater than { missing_ratio } will be dropped (independent execution)'
1121 elif name == 'Step7-IV处理' :
1122 # Operation result: number of dropped features
1123 if '变量' in df.columns:
1124 result = f 'Dropped { len (df) } features'
1125 else :
1126 result = f 'Dropped { len (df) } '
1127 # Condition: parameter standards
1128 condition = f 'Features with overall IV less than { overall_iv_threshold } or IV less than { org_iv_threshold } in { max_org_threshold } or more organizations will be dropped (independent execution)'
1129 elif name == 'Step8-PSI处理' :
1130 # Operation result: number of dropped features
1131 if '变量' in df.columns:
1132 result = f 'Dropped { len (df) } features'
1133 else :
1134 result = f 'Dropped { len (df) } '
1135 # Condition: parameter standards
1136 condition = f 'PSI threshold { psi_threshold } , if an organization has more than { max_months_ratio :.0%} months with PSI greater than { psi_threshold } , the organization is considered unstable, if more than { max_orgs } organizations are unstable, the feature will be dropped (independent execution)'
1137 elif name == 'Step9-null importance处理' :
1138 # Operation result: number of dropped features
1139 if '变量' in df.columns:
1140 result = f 'Dropped { len (df[df[ "状态" ] == "去除" ]) } features'
1141 else :
1142 result = f 'Dropped { len (df) } '
1143 # Condition: parameter standards
1144 condition = f 'Features with absolute difference of gain values before and after permutation less than { gain_threshold } will be identified as noise and dropped (independent execution)'
1145 elif name == 'Step10-高相关性剔除' :
1146 # Operation result: number of dropped features
1147 if '变量' in df.columns:
1148 result = f 'Dropped { len (df) } features'
1149 else :
1150 result = f 'Dropped { len (df) } '
1151 # Condition: parameter standards
1152 max_corr = params.get( 'max_corr' , 0.9 )
1153 top_n_keep = params.get( 'top_n_keep' , 20 )
1154 condition = f 'Features with correlation greater than { max_corr } will be dropped, keep top { top_n_keep } features by original gain ranking (independent execution)'
1155 else :
1156 result = 'Dropped ' + str ( len (df))
1157 condition = ''
1158 else :
1159 result = 'Empty'
1160 condition = ''
1161
1162 ws.cell( 4 + step_num, 3 , result)
1163 ws.cell( 4 + step_num, 4 , condition)
1164 step_num += 1
1165
1166 # Calculate total number of dropped features (take union of dropped features from each step)
1167 all_dropped_vars = set ()
1168 for name, df in steps:
1169 if name in filter_steps and df is not None and len (df) > 0 and '变量' in df.columns:
1170 if name == 'Step9-null importance处理' :
1171 # null importance processing needs to filter features with status "去除"
1172 dropped_vars = df[df[ '状态' ] == '去除' ][ '变量' ].tolist()
1173 else :
1174 dropped_vars = df[ '变量' ].tolist()
1175 # Take union (deduplicated)
1176 all_dropped_vars = all_dropped_vars.union( set (dropped_vars))
1177
1178 # Add final statistics row
1179 final_step_num = step_num
1180 ws.cell( 4 + final_step_num, 1 , final_step_num)
1181 ws.cell( 4 + final_step_num, 2 , 'Final Dropped Features Statistics' )
1182 ws.cell( 4 + final_step_num, 3 , f 'Total dropped { len (all_dropped_vars) } features (union of dropped features from each step)' )
1183 ws.cell( 4 + final_step_num, 4 , 'Each step is executed independently, final dropped features are the union of dropped features from each step' )
1184
1185 # Details of each step (create sheets in step progression order)
1186 # Define sheet creation order
1187 sheet_order = [
1188 '机构样本统计' , '分离OOS数据' , 'Step4-异常月份处理' , '缺失率明细' ,
1189 'Step5-有值率分布统计' , 'Step6-高缺失率处理' , 'Step7-IV明细' , 'Step7-IV处理' ,
1190 'Step7-IV分布统计' , 'Step8-PSI明细' , 'Step8-PSI处理' , 'Step8-PSI分布统计' ,
1191 'Step9-null importance处理' , 'Step10-高相关性剔除'
1192 ]
1193
1194 # Create sheets in order
1195 for sheet_name in sheet_order:
1196 # Find corresponding DataFrame in steps
1197 df = None
1198 for name, step_df in steps:
1199 if name == sheet_name:
1200 df = step_df
1201 break
1202
1203 if df is not None :
1204 if sheet_name in wb.sheetnames:
1205 del wb[sheet_name]
1206 ws_detail = wb.create_sheet(sheet_name)
1207
1208 for j, col in enumerate (df.columns):
1209 ws_detail.cell( 1 , j + 1 , col)
1210
1211 for i, row in df.iterrows():
1212 for j, val in enumerate (row):
1213 # Write value directly, avoid character escaping issues
1214 ws_detail.cell(i + 2 , j + 1 , val if val is not None else '' )
1215
1216 header_fill = PatternFill( start_color = "366092" , end_color = "366092" , fill_type = "solid" )
1217 header_font = Font( color = "FFFFFF" , bold = True )
1218 for cell in ws_detail[ 1 ]:
1219 cell.fill = header_fill
1220 cell.font = header_font
1221
1222 wb.save(filepath)
1223 print ( f "Report saved: { filepath } " )