TayyabManan's picture
Initial commit: Visa Approval Predictor
e7919f5
Raw
History Blame Contribute Delete
7.58 kB
import shap
from visa_approval_prediction.logger import logging
class VisaShapExplainer:
"""Generates SHAP-based explanations for visa approval predictions."""
def __init__(self, model):
"""
Args:
model: visaModel with preprocessing_object (ColumnTransformer) and
trained_model_object (tree-based classifier).
"""
self.preprocessor = model.preprocessing_object
self.tree_model = model.trained_model_object
self.explainer = shap.TreeExplainer(self.tree_model)
self.feature_mapping = self._build_feature_mapping()
logging.info(
f"SHAP explainer initialized "
f"(model={type(self.tree_model).__name__}, "
f"transformed_features={len(self.feature_mapping)})"
)
def _build_feature_mapping(self):
"""Map each transformed feature index back to its original feature name."""
mapping = []
for name, transformer, columns in self.preprocessor.transformers_:
if name == 'remainder':
continue
if name == 'OneHotEncoder':
for i, col in enumerate(columns):
n_cats = len(transformer.categories_[i])
mapping.extend([col] * n_cats)
elif name == 'Transformer':
for col in columns:
mapping.append(col)
else:
for col in columns:
mapping.append(col)
return mapping
def explain(self, input_df, input_data, prediction_result):
"""Generate SHAP-based explanation in the same format as analyze_features()."""
transformed = self.preprocessor.transform(input_df)
raw_shap = self.explainer.shap_values(transformed)
# XGBoost returns a single ndarray; RandomForest returns [class0, class1]
if isinstance(raw_shap, list):
shap_values = raw_shap[1][0]
else:
shap_values = raw_shap[0]
# Aggregate transformed-feature SHAP values back to original features.
feature_shap = {}
for idx, feature_name in enumerate(self.feature_mapping):
if idx < len(shap_values):
feature_shap[feature_name] = (
feature_shap.get(feature_name, 0.0) + shap_values[idx]
)
# Sort by absolute SHAP magnitude (most impactful first)
sorted_features = sorted(
feature_shap.items(), key=lambda x: abs(x[1]), reverse=True
)
strengths = []
weaknesses = []
suggestions = []
# Target encoding: Certified=0, Denied=1
# Positive SHAP -> pushes toward Denied (weakness)
# Negative SHAP -> pushes toward Certified (strength)
for feature_name, shap_val in sorted_features:
if abs(shap_val) < 0.01:
continue
msg = self._format_message(feature_name, shap_val, input_data)
if shap_val < 0:
strengths.append(msg)
else:
weaknesses.append(msg)
suggestion = self._get_suggestion(feature_name, input_data)
if suggestion:
suggestions.append(suggestion)
n_strong = sum(1 for _, v in sorted_features if v < -0.3)
confidence_label = (
"high" if n_strong >= 4 else "moderate" if n_strong >= 2 else "low"
)
return {
'strengths': strengths,
'weaknesses': weaknesses,
'suggestions': suggestions,
'confidence_label': confidence_label,
}
def _get_suggestion(self, feature_name, input_data):
"""Return an input-aware suggestion for a weakness."""
value = input_data.get(feature_name, '')
if feature_name == 'education_of_employee':
if value in ("Master's", "Doctorate"):
return None # already high education, no suggestion
elif value == "Bachelor's":
return "A Master's or Doctorate degree would strengthen the application"
else:
return "A Bachelor's or higher degree improves approval chances"
elif feature_name == 'has_job_experience':
return "Gaining relevant work experience strengthens applications"
elif feature_name == 'requires_job_training':
if value == "Y":
return "Applicants not requiring training have higher approval rates"
return None
elif feature_name == 'prevailing_wage':
return "Higher-paying positions correlate with better approval odds"
elif feature_name == 'no_of_employees':
return "Larger employers tend to have smoother PERM processes"
elif feature_name == 'full_time_position':
if value != "Y":
return "Full-time positions demonstrate stronger employer commitment"
return None
elif feature_name == 'company_age':
return "More established companies have stronger approval track records"
return None
def _format_message(self, feature_name, shap_val, input_data):
"""Create a human-readable message for a single feature's SHAP contribution."""
intensity = self._get_intensity(abs(shap_val))
value = input_data.get(feature_name, '')
# Negative SHAP -> Certified (favors), Positive -> Denied (works against)
direction = 'favors' if shap_val < 0 else 'works against'
if feature_name == 'education_of_employee':
return f"{value} education {intensity} {direction} approval"
elif feature_name == 'has_job_experience':
exp = "Having" if value == "Y" else "Not having"
return f"{exp} job experience {intensity} {direction} approval"
elif feature_name == 'requires_job_training':
trn = "Requiring" if value == "Y" else "Not requiring"
return f"{trn} job training {intensity} {direction} approval"
elif feature_name == 'full_time_position':
pos = "Full-time" if value == "Y" else "Part-time"
return f"{pos} position {intensity} {direction} approval"
elif feature_name == 'no_of_employees':
try:
return f"Company size ({int(value):,} employees) {intensity} {direction} approval"
except (ValueError, TypeError):
return f"Company size {intensity} {direction} approval"
elif feature_name == 'company_age':
return f"Company age ({value} years) {intensity} {direction} approval"
elif feature_name == 'prevailing_wage':
try:
return f"Prevailing wage (${float(value):,.0f}) {intensity} {direction} approval"
except (ValueError, TypeError):
return f"Prevailing wage {intensity} {direction} approval"
elif feature_name == 'continent':
return f"Applicant from {value} {intensity} {direction} approval"
elif feature_name == 'region_of_employment':
return f"Employment in {value} {intensity} {direction} approval"
elif feature_name == 'unit_of_wage':
return f"Wage unit ({value}) {intensity} {direction} approval"
else:
return f"{feature_name} ({value}) {intensity} {direction} approval"
@staticmethod
def _get_intensity(abs_shap):
if abs_shap > 1.0:
return 'strongly'
elif abs_shap > 0.3:
return 'moderately'
return 'slightly'