Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,6 @@ scikit-learn
matplotlib
seaborn
scipy
collections
random
skrebate==0.7
os
time
copy
math
ast
networkx
itertools
pickle

151 changes: 122 additions & 29 deletions src/skheros/heros.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ def offspring_improves(self, offspring_list):



def predict_explanation(self, x, feature_names, whole_rule_pop=False, target_model=0):
def predict_explanation(self, x, feature_names, whole_rule_pop=False, target_model=0, verbose=True):
""" Applies model to predict a single instance outcome with full explanation of prediction. """
# Data point checks ************************
for value in x:
Expand All @@ -687,6 +687,7 @@ def predict_explanation(self, x, feature_names, whole_rule_pop=False, target_mod
outcome_proba = prediction.get_prediction_proba_dictionary()
outcome_coverage = prediction.get_if_covered()
match_set = self.rule_population.match_set
rule_source = self.rule_population.pop_set
self.rule_population.clear_sets()
else:
self.model_population.get_target_model(target_model)
Expand All @@ -697,47 +698,139 @@ def predict_explanation(self, x, feature_names, whole_rule_pop=False, target_mod
outcome_proba = prediction.get_prediction_proba_dictionary()
outcome_coverage = prediction.get_if_covered()
match_set = self.model_population.match_set
rule_source = self.model_population.target_rule_set
self.model_population.clear_sets()

# Technical Report of Matching Rules ------------------------------------------
print("PREDICTION REPORT ------------------------------------------------------------------")
print("Outcome Prediction: "+str(outcome_prediction))
print("Model Prediction Probabilities: "+ str(outcome_proba))
if outcome_coverage == 0:
print("Instance Covered by Model: No")
else:
print("Instance Covered by Model: Yes")
print("Number of Matching Rules: "+str(len(match_set)))
# TECHNICAL RULE REPORT
#for rule_index in match_set:
# self.model_population.target_rule_set[rule_index].display_key_rule_info()
print("PREDICTION EXPLANATION -------------------------------------------------------------")
if prediction.majority_class_selection_made:
print("Majority class selected since there is probability tie among matching rules, but there is a training majority class")
if prediction.random_selection_made:
print("Random class selected since there is probability tie among matching rules, but no training majority class")
if verbose:
print("PREDICTION REPORT ------------------------------------------------------------------")
print("Outcome Prediction: "+str(outcome_prediction))
print("Model Prediction Probabilities: "+ str(outcome_proba))
if outcome_coverage == 0:
print("Instance Covered by Model: No")
else:
print("Instance Covered by Model: Yes")
print("Number of Matching Rules: "+str(len(match_set)))
# TECHNICAL RULE REPORT
#for rule_index in match_set:
# self.model_population.target_rule_set[rule_index].display_key_rule_info()
print("PREDICTION EXPLANATION -------------------------------------------------------------")
if prediction.majority_class_selection_made:
print("Majority class selected since there is probability tie among matching rules, but there is a training majority class")
if prediction.random_selection_made:
print("Random class selected since there is probability tie among matching rules, but no training majority class")
if len(match_set) > 0:
# Sort match set for intuitive ordering
match_set = sorted(match_set, key=lambda i: (self.model_population.target_rule_set[i].numerosity, self.model_population.target_rule_set[i].correct_cover), reverse=True)
match_set = sorted(match_set, key=lambda i: (rule_source[i].numerosity, rule_source[i].correct_cover), reverse=True)
# Give explanations for matching rules
print("Supporting Rules: --------------------")
if verbose:
print("Supporting Rules: --------------------")
for rule_index in match_set:
if str(self.model_population.target_rule_set[rule_index].action) == str(prediction.prediction):
self.model_population.target_rule_set[rule_index].translate_rule(feature_names,self)
print("Contradictory Rules: -----------------")
if str(rule_source[rule_index].action) == str(prediction.prediction):
if verbose:
rule_source[rule_index].translate_rule(feature_names,self)
if verbose:
print("Contradictory Rules: -----------------")
counter = 0
for rule_index in match_set:
if str(self.model_population.target_rule_set[rule_index].action) != str(prediction.prediction):
self.model_population.target_rule_set[rule_index].translate_rule(feature_names,self)
if str(rule_source[rule_index].action) != str(prediction.prediction):
if verbose:
rule_source[rule_index].translate_rule(feature_names,self)
counter += 1
if counter == 0:
if counter == 0 and verbose:
print("No contradictory rules matched.")
else: # No matching rules
if prediction.random_selection_made:
print("Random class selected since there are no matching rules and no training majority class")
if verbose:
if prediction.random_selection_made:
print("Random class selected since there are no matching rules and no training majority class")
else:
print("Majority class selected since there are no matching rules, but there is a training majority class")

# Build and return structured explanation for programmatic use
features_view = [
{
"feature_index": idx,
"feature_name": feature_names[idx],
"value": x[idx]
}
for idx in range(len(x))
]

supporting_rules = []
contradictory_rules = []
per_rule_contributions = []
for rule_index in match_set:
rule_obj = rule_source[rule_index]
rule_dict = rule_obj.to_explanation_dict(feature_names, self)
# compute this rule's weighted vote contribution (classification only)
vote_contrib = {}
if hasattr(rule_obj, 'instance_outcome_prop') and isinstance(outcome_proba, dict):
for cls, prob in rule_obj.instance_outcome_prop.items():
vote_contrib[cls] = prob * rule_obj.numerosity
rule_dict["vote_contribution"] = vote_contrib
rule_dict["selected_action_matches_prediction"] = (str(rule_obj.action) == str(outcome_prediction))
if str(rule_obj.action) == str(outcome_prediction):
supporting_rules.append(rule_dict)
else:
print("Majority class selected since there are no matching rules, but there is a training majority class")

contradictory_rules.append(rule_dict)
per_rule_contributions.append({
"rule_id": getattr(rule_obj, "ID", None),
"numerosity": rule_obj.numerosity,
"action": rule_obj.action,
"vote_contribution": vote_contrib
})

selection_reason = None
if prediction.majority_class_selection_made and len(match_set) > 0:
selection_reason = "tie_break_by_training_majority"
elif prediction.random_selection_made and len(match_set) > 0:
selection_reason = "tie_break_random"
elif prediction.random_selection_made and len(match_set) == 0:
selection_reason = "no_matching_rules_random"
elif not prediction.random_selection_made and not prediction.majority_class_selection_made and len(match_set) == 0:
selection_reason = "no_matching_rules_training_majority"

structured = {
"outcome_prediction": outcome_prediction,
"prediction_probabilities": outcome_proba,
"covered": bool(outcome_coverage),
"num_matching_rules": len(match_set),
"whole_rule_population": bool(whole_rule_pop),
"target_model_index": int(target_model) if not whole_rule_pop else None,
"selection_reason": selection_reason,
"algorithm": {
"outcome_type": self.outcome_type,
"classes": list(self.env.classes) if hasattr(self.env, 'classes') else None,
"voting_scheme": "whole_population" if whole_rule_pop else "top_model_rule_set",
"numerosity_sum": getattr(prediction, 'numerosity_sum', None),
"tie_breaking": {
"majority_class": bool(getattr(prediction, 'majority_class_selection_made', False)),
"random": bool(getattr(prediction, 'random_selection_made', False))
}
},
"features": features_view,
"supporting_rules": supporting_rules,
"contradictory_rules": contradictory_rules,
"per_rule_contributions": per_rule_contributions,
"match_set_rule_ids": [getattr(rule_source[i], 'ID', None) for i in match_set]
}

# A short narrative for user-facing explanation layers
try:
num_support = len(supporting_rules)
num_contra = len(contradictory_rules)
coverage_text = "covered" if structured["covered"] else "not covered"
tie_text = " with tie broken by training majority" if structured["algorithm"]["tie_breaking"]["majority_class"] else (" with random tie-break" if structured["algorithm"]["tie_breaking"]["random"] else "")
narrative = (
"Instance is "+coverage_text+" by "+str(structured["num_matching_rules"]) +
" rule(s); " + str(num_support) + " support the predicted class '"+str(outcome_prediction)+"' and " +
str(num_contra) + " contradict. Prediction made via " + structured["algorithm"]["voting_scheme"] + tie_text + "."
)
structured["narrative"] = narrative
except Exception:
structured["narrative"] = None

return structured

def predict(self, X, whole_rule_pop=False, target_model=0, rule_pop_iter=None, model_pop_iter=None):
"""Scikit-learn required: Apply trained model to predict outcomes of instances.
Expand Down
58 changes: 57 additions & 1 deletion src/skheros/methods/rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def __init__(self,heros):
self.ave_match_set_size = 1 #average size of the match sets in which this rule was included across all training instances - used in deletion to promote niching
self.deletion_prob = None #probability of rule being selected for deletion
self.prediction = None
#self.encoding = None
self.encoding = None

def __eq__(self, other):
return isinstance(other, RULE) and self.ID == other.ID
Expand Down Expand Up @@ -821,6 +821,62 @@ def translate_rule(self,feature_names,heros):
translation += " THEN: predict outcome '"+str(self.action)+"' with "+str(100 * self.instance_outcome_prop[self.action])+"% confidence based on "+str(self.match_cover)+' matching training instances ('+str(round(100*self.match_cover /float(heros.env.num_instances),2))+"% of training instances)."
print(translation)

def to_explanation_dict(self, feature_names, heros):
"""Return a structured, LLM-friendly explanation of this rule.

The structure includes both machine- and human-readable fields for conditions and
key rule statistics useful for downstream reasoning layers.
"""
# Ensure deterministic ordering of conditions
self.order_rule_conditions()
conditions = []
for i in range(len(self.condition_indexes)):
feature_index = self.condition_indexes[i]
value = self.condition_values[i]
is_categorical = (heros.env.feat_types[feature_index] == 1)
if is_categorical:
human = {
"text": str(feature_names[feature_index])+" = "+str(value)
}
cond = {
"feature_index": feature_index,
"feature_name": feature_names[feature_index],
"type": "categorical",
"operator": "=",
"value": value,
"human_readable": human["text"]
}
else:
# quantitative range (min, max)
range_min, range_max = value[0], value[1]
human_text = str(feature_names[feature_index])+" in ["+str(range_min)+", "+str(range_max)+"]"
cond = {
"feature_index": feature_index,
"feature_name": feature_names[feature_index],
"type": "quantitative",
"operator": "in_range",
"min": range_min,
"max": range_max,
"human_readable": human_text
}
conditions.append(cond)

explanation = {
"rule_id": getattr(self, "ID", None),
"action": self.action,
"instance_outcome_proportions": dict(self.instance_outcome_prop) if hasattr(self, "instance_outcome_prop") else {},
"numerosity": self.numerosity,
"fitness": self.fitness,
"accuracy": self.accuracy,
"match_cover": self.match_cover,
"correct_cover": self.correct_cover,
"average_match_set_size": self.ave_match_set_size,
"deletion_probability": self.deletion_prob,
"birth_iteration": self.birth_iteration,
"conditions": conditions
}
return explanation


def order_rule_conditions(self):
""" Order the rule conditions by increasing feature index; keeping the ordering consistent between condition_indexes and condition_values."""
Expand Down
Loading