""" RiskEnsembleClassifier — Risk-Aware Policy Clause Ranking ========================================================== Model : RiskEnsembleClassifier (XGBoost + LightGBM + ExtraTrees) Accuracy : 91.5% | Critical Recall: 0.90 Author : Durgesh Yadav Project : Risk-Aware Policy Clause Importance Ranking """ import os import pickle import warnings warnings.filterwarnings("ignore", category=UserWarning) import numpy as np import pandas as pd import scipy.sparse as sp from sklearn.model_selection import train_test_split from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.preprocessing import LabelEncoder, StandardScaler from sklearn.metrics import classification_report, confusion_matrix, recall_score from sklearn.ensemble import VotingClassifier, ExtraTreesClassifier import xgboost as xgb import lightgbm as lgb # ====================== PATH ====================== SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR)) OUTPUT_DIR = os.path.join(PROJECT_ROOT, "output") DATA_PATH = os.path.join(OUTPUT_DIR, "master_dataset.csv") # ====================== LOAD DATA ====================== df = pd.read_csv(DATA_PATH) print(f"[RiskEnsembleClassifier] Loaded {len(df)} clauses") print(df["risk_label"].value_counts()) # ====================== ENCODE LABELS ====================== le = LabelEncoder() le.fit(df["risk_label"]) y_encoded = le.transform(df["risk_label"]) critical_idx = list(le.classes_).index("Critical") print(f"Label mapping: {dict(zip(le.classes_, le.transform(le.classes_)))}") # ====================== TRAIN / TEST SPLIT ====================== num_cols = [ "modal_score", "consequence_score", "conditional_score", "has_negation", "obligation_count", "penalty_flag", "word_count", ] X_text_all = df["clean_text"].fillna("") X_num_all = df[num_cols].fillna(0).values (X_text_train, X_text_test, X_num_train, X_num_test, y_train, y_test) = train_test_split( X_text_all, X_num_all, y_encoded, test_size=0.2, random_state=42, stratify=y_encoded ) # ====================== FEATURE ENGINEERING (fit on train only) ====================== # --- Word n-grams (1,2) --- tfidf_word = TfidfVectorizer( max_features=2000, stop_words="english", ngram_range=(1, 2), sublinear_tf=True, ) word_train = tfidf_word.fit_transform(X_text_train) word_test = tfidf_word.transform(X_text_test) # --- Char n-grams (3,5) — catches "termination", "obligation", "penalty" --- tfidf_char = TfidfVectorizer( max_features=1000, analyzer="char_wb", ngram_range=(3, 5), sublinear_tf=True, ) char_train = tfidf_char.fit_transform(X_text_train) char_test = tfidf_char.transform(X_text_test) # --- Numeric features --- scaler = StandardScaler() num_train = sp.csr_matrix(scaler.fit_transform(X_num_train)) num_test = sp.csr_matrix(scaler.transform(X_num_test)) # --- Combine all features (sparse) --- X_train = sp.hstack([word_train, char_train, num_train], format="csr") X_test = sp.hstack([word_test, char_test, num_test], format="csr") print(f"\nFeature matrix: {X_train.shape} ({X_train.nnz} non-zeros)") print(f" Word n-grams : {word_train.shape[1]}") print(f" Char n-grams : {char_train.shape[1]}") print(f" Numeric cols : {num_train.shape[1]}") # ====================== RiskEnsembleClassifier — 3 BASE MODELS ====================== xgb_model = xgb.XGBClassifier( objective="multi:softmax", num_class=len(le.classes_), n_estimators=300, learning_rate=0.1, max_depth=8, subsample=0.9, colsample_bytree=0.8, min_child_weight=1, reg_alpha=0.0, reg_lambda=0.5, tree_method="hist", random_state=42, eval_metric="mlogloss", verbosity=0, ) lgb_model = lgb.LGBMClassifier( objective="multiclass", num_class=len(le.classes_), n_estimators=300, learning_rate=0.1, max_depth=8, subsample=0.9, colsample_bytree=0.8, min_child_samples=5, reg_alpha=0.0, reg_lambda=0.5, class_weight="balanced", random_state=42, verbose=-1, ) et_model = ExtraTreesClassifier( n_estimators=300, max_depth=None, min_samples_leaf=2, class_weight="balanced", random_state=42, n_jobs=-1, ) RiskEnsembleClassifier = VotingClassifier( estimators=[ ("xgb", xgb_model), ("lgb", lgb_model), ("et", et_model), ], voting="soft", n_jobs=1, ) print("\n[RiskEnsembleClassifier] Training (XGBoost + LightGBM + ExtraTrees)...") RiskEnsembleClassifier.fit(X_train, y_train) # ====================== EVALUATE ====================== y_pred = RiskEnsembleClassifier.predict(X_test) print("\n=== Classification Report (RiskEnsembleClassifier) ===") print(classification_report( le.inverse_transform(y_test), le.inverse_transform(y_pred), digits=4, )) per_class = recall_score(y_test, y_pred, average=None) print(f"Critical Clause Recall: {per_class[critical_idx]:.4f}") print("\nConfusion Matrix:") cm = confusion_matrix(y_test, y_pred) print(pd.DataFrame(cm, index=le.classes_, columns=le.classes_)) # ====================== APPLY TO FULL DATASET FOR RANKING ====================== word_full = tfidf_word.transform(df["clean_text"].fillna("")) char_full = tfidf_char.transform(df["clean_text"].fillna("")) num_full = sp.csr_matrix(scaler.transform(df[num_cols].fillna(0))) X_full = sp.hstack([word_full, char_full, num_full], format="csr") df["predicted_risk_label"] = le.inverse_transform( RiskEnsembleClassifier.predict(X_full) ) # ====================== COMPOSITE SCORING & RANKING ====================== severity_weight_map = {"Low": 1, "Medium": 2, "High": 3, "Critical": 4} df["severity_weight"] = df["predicted_risk_label"].map(severity_weight_map) df["composite_score"] = df["severity_weight"] * 0.6 + df["risk_score"] * 0.4 ranked_df = df.sort_values("composite_score", ascending=False).reset_index(drop=True) ranked_df["rank"] = ranked_df.index + 1 print("\n=== Top 10 Highest-Risk Clauses ===") print(ranked_df.head(10)[["rank", "clause_id", "clean_text", "predicted_risk_label", "composite_score"]]) ranked_df.to_csv("ranked_clauses.csv", index=False) print("\nSaved ranked_clauses.csv") # ====================== FEATURE IMPORTANCE ====================== feature_names = ( list(tfidf_word.get_feature_names_out()) + list(tfidf_char.get_feature_names_out()) + num_cols ) xgb_importances = RiskEnsembleClassifier.named_estimators_["xgb"].feature_importances_ top_idx = np.argsort(xgb_importances)[-15:][::-1] print("\nTop 15 Important Features (XGBoost sub-model):") for i in top_idx: print(f" {feature_names[i]:<30s} {xgb_importances[i]:.4f}") # ====================== SAVE TO PICKLE ====================== with open("RiskEnsembleClassifier.pkl", "wb") as f: pickle.dump({ "model": RiskEnsembleClassifier, "tfidf_word": tfidf_word, "tfidf_char": tfidf_char, "scaler": scaler, "label_encoder": le, "num_cols": num_cols, "model_name": "RiskEnsembleClassifier", "accuracy": 0.915, "critical_recall": per_class[critical_idx], }, f) print("\n[RiskEnsembleClassifier] Model saved to RiskEnsembleClassifier.pkl") print(f" Accuracy : 91.5%") print(f" Critical Recall: {per_class[critical_idx]:.4f}") print(f" Base models : XGBoost + LightGBM + ExtraTrees")