import torch
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from torch import nn
from neuro_fuzzy_toolbox.models import ANFIS, h_ANFIS, rule_reduced_ANFIS
[docs]
class RulesAnalyzer:
"""
Analysis and interpretability tool for ANFIS rules.
Provides methods for:
- Identifying the most active rules for specific samples.
- Exporting rules in human-readable IF-THEN format.
- Analyzing rule contributions to model predictions.
- Visualizing and estimating rule similarity.
"""
[docs]
def __init__(self, model):
"""
Initializes the RulesAnalyzer.
Args:
model (ANFIS | h_ANFIS | rule_reduced_ANFIS): Trained ANFIS model
to analyze.
"""
self.model = model
self.linguistic_labels = {}
self.num_outputs = self.model.outputs
self.output_type = self.model._output_type
self.is_classification = (self.output_type == 'softmax')
def _standardize_input(self, x):
"""
Ensures that the input tensor has the correct shape for the model.
Args:
x (torch.Tensor): Input sample.
Returns:
torch.Tensor: Input sample with standardized shape ``(1, n_features)``.
Raises:
ValueError: If ``x`` is not a 1D tensor or a 2D tensor with a single sample.
"""
if x.dim() == 1:
return x.unsqueeze(0) # Convertir a (1, features)
elif x.dim() == 2 and x.shape[0] == 1:
return x # Ya tiene forma (1, features)
else:
raise ValueError(f"Input x must be a 1D tensor or a 2D tensor with a single sample. Current shape: {x.shape}")
[docs]
def layers_outputs(self, x):
"""
Returns the relevant intermediate layer outputs of the model for a
single input sample.
Note:
If the model has ``output_type='default'``, the output dictionary
includes: ``'membership values'``, ``'firing levels'``,
``'norm firing levels'``, ``'consequent outputs'``,
``'rules contribution'``, and ``'final output'``. If the model
has ``output_type='softmax'``, the dictionary additionally includes
``'logits'`` (the output before the softmax function).
Args:
x (torch.Tensor): Input sample of shape ``(n_features,)`` or ``(1, n_features)``.
Returns:
dict: Dictionary containing the relevant outputs of each layer.
"""
x = self._standardize_input(x)
with torch.no_grad():
membership_values = self.model._fuzzification_layer(x)
firing_levels = self.model._firing_levels_layer(membership_values)
norm_firing_levels = self.model._normalization_layer(firing_levels)
weighted_rules_outputs = self.model._consequent_layer(x, norm_firing_levels)
output = self.model._output_layer(weighted_rules_outputs, return_probs=False)
dict_output = {
'membership values': membership_values,
'firing levels': firing_levels,
'norm firing levels': norm_firing_levels,
'consequent outputs': self.model.get_all_consequents_outputs(x, weighted=False),
'rules contribution': weighted_rules_outputs,
}
if self.is_classification:
dict_output['logits'] = output
with torch.no_grad():
dict_output['final output'] = self.model._output_layer(weighted_rules_outputs, return_probs=True)
else:
dict_output['final output'] = output
return dict_output
def _classification_rule_scores(self, logits, probs, rules_contributions, class_idx):
"""
Computes post-hoc rule relevance measures for a specific class.
Args:
logits (torch.Tensor): Model logits for the analyzed sample, of shape ``(n_classes,)``.
probs (torch.Tensor): Final model probabilities for the analyzed sample, of shape ``(n_classes,)``.
rules_contributions (torch.Tensor): Contribution of each rule to each class in the logit space, of shape ``(n_classes, n_rules)``.
class_idx (int): Index of the target class :math:`c`.
Returns:
tuple[torch.Tensor, torch.Tensor, torch.Tensor]: A tuple of three tensors of shape ``(n_rules,)`` containing
``I_logit_margin_max``, ``I_logit_margin_mean``, and ``I_prob``, respectively.
Note:
Let :math:`\\Delta z_{r,c}` be the contribution of rule :math:`r` to the logit of class :math:`c`, and let
:math:`\\Delta \\mathbf{z}_r` be the full vector of contributions of that rule across all classes.
The computed measures are:
**1) Maximum logit margin per class**
.. math::
I^{(c)}_{\\text{logit\\_margin\\_max}}(r)
=
\\Delta z_{r,c}
-
\\max_{j \\neq c} \\Delta z_{r,j}
Favors rules that increase the logit of the target class more than any other class.
**2) Mean logit margin per class**
.. math::
I^{(c)}_{\\text{logit\\_margin\\_mean}}(r)
=
\\Delta z_{r,c}
-
\\frac{1}{C-1} \\sum_{j \\neq c} \\Delta z_{r,j}
Compares the rule's contribution to the target class against the average of its contributions to all other classes.
**3) Leave-one-rule-out probability**
.. math::
I^{(c)}_{\\text{prob}}(r)
=
p_c(\\mathbf{z})
-
p_c(\\mathbf{z} - \\Delta \\mathbf{z}_r)
where :math:`p_c(\\mathbf{z})` is the softmax probability of the target class using all rules, and
:math:`p_c(\\mathbf{z} - \\Delta \\mathbf{z}_r)` is the probability obtained by removing the contribution of rule
:math:`r`.
A positive value indicates that the rule supports the probability of the target class; a negative value indicates that
it harms it.
"""
no_pred_classes_contributions = torch.cat([rules_contributions[:class_idx], rules_contributions[class_idx+1:]], dim=0)
pred_class_contribution = rules_contributions[class_idx, :]
I_logit_margin_max = pred_class_contribution - torch.max(no_pred_classes_contributions, dim=0).values
I_logit_margin_mean = pred_class_contribution - torch.mean(no_pred_classes_contributions, dim=0)
I_prob = (probs - nn.functional.softmax(logits - rules_contributions.t(), dim=1))[:, class_idx] # leave one rule out -> probs
""" this is the same as:
I_prob = []
for i in range(model4.rules):
z_without_r = logits - contribution[:,i]
p_without_r = nn.functional.softmax(z_without_r, dim=0)
I_prob_r = real_prob[pred_idx] - p_without_r[pred_idx]
I_prob.append(I_prob_r)
I_prob = torch.tensor(I_prob)
"""
return I_logit_margin_max, I_logit_margin_mean, I_prob
[docs]
def top_activated_rules(self, x, top_k=None, output_idx=None, sort_by='firing_levels'):
"""
Identifies and ranks the top-k most active rules for a given input
sample.
Args:
x (torch.Tensor): Input sample of shape ``(n_features,)`` or ``(1, n_features)``.
top_k (int, optional): Number of top rules to return. If ``None``, all rules are included. Defaults to ``None``.
output_idx (int, optional): Index of the output to analyze. Only used in regression models with multiple outputs;
ignored otherwise. Defaults to ``None``.
sort_by (str, optional): Criterion for ranking the active rules. Available options are ``'firing_levels'``,
``'abs_rules_outputs'``, ``'rules_outputs'``, ``'abs_contribution'``, and ``'contribution'``.
For models with ``output_type='softmax'``, the additional options ``'leave_one_rule_out'``, ``'logit_margin'``,
and ``'logit_margin_mean'`` are also available. Defaults to ``'firing_levels'``.
Returns:
pandas.DataFrame | dict: The return type depends on the model's output type and the value of ``output_idx``:
- **Regression models** (``output_type='default'``): Returns a ``pandas.DataFrame`` with columns ``'rule_id'``,
``'firing_level'``, ``'rule_output'``, and ``'contribution'`` when a single output is analyzed (either because
the model has one output or ``output_idx`` is specified). Returns a ``dict[str, pandas.DataFrame]`` with keys
``'output_0'``, ``'output_1'``, ... when the model has multiple outputs and ``output_idx`` is ``None``.
- **Classification models** (``output_type='softmax'``): Returns a ``pandas.DataFrame`` with columns ``'rule_id'``,
``'firing_level'``, ``'rule_output'``, ``'contribution'``, ``'I_logit_margin_max'``, ``'I_logit_margin_mean'``, and
``'I_prob'`` when ``output_idx`` is specified. Returns a ``dict[str, pandas.DataFrame]`` with keys ``'class_0'``,
``'class_1'``, ... (or custom class label keys if the model uses custom class ids) when ``output_idx`` is ``None``.
Note:
Available sorting criteria:
- ``'firing_levels'``: Sorts by normalized firing levels (:math:`w`).
- ``'abs_rules_outputs'``: Sorts by the absolute value of each rule's individual output before weighting by firing levels (:math:`f(x)` without multiplying by :math:`w`).
- ``'rules_outputs'``: Sorts by each rule's individual output before weighting by firing levels.
- ``'abs_contribution'``: Sorts by the absolute value of each rule's contribution to the final output (:math:`f(x) \\cdot w`).
- ``'contribution'``: Sorts by each rule's contribution to the final output.
- ``'leave_one_rule_out'`` (``output_type='softmax'`` only): Sorts by the impact on the target class probability when the rule is removed, computed as the difference between the full-model probability and the leave-one-out probability.
- ``'logit_margin'`` (``output_type='softmax'`` only): Sorts by the difference between the rule's contribution to the target class logit and its contribution to the highest-scoring alternative class.
- ``'logit_margin_mean'`` (``output_type='softmax'`` only): Sorts by the difference between the rule's contribution to the target class logit and the mean of its contributions to all other classes.
"""
x = self._standardize_input(x)
all_layers_outputs = self.layers_outputs(x)
# firing levels
w = all_layers_outputs['norm firing levels'].squeeze(0) # (R,)
# consequent raw outputs (unweighted): (O, B, R) -> (O, R)
consequent_outputs = all_layers_outputs['consequent outputs'][:, 0, :]
# each rule output per model output/class: (O, R)
rules_contributions = all_layers_outputs['rules contribution'].squeeze(1)
# decide outputs/classes to analyze
if output_idx is not None:
outputs_to_analyze = [output_idx]
elif self.num_outputs == 1:
outputs_to_analyze = [0]
else:
outputs_to_analyze = list(range(self.num_outputs))
results_dict = {}
##################################################################
######################### CLASSIFICATION #########################
##################################################################
if self.is_classification:
logits = all_layers_outputs['logits'].squeeze(0) # (C,)
pred_probs = all_layers_outputs['final output'].squeeze(0) # (C,)
for out_idx in outputs_to_analyze:
I_logit_margin_max, I_logit_margin_mean, I_prob = self._classification_rule_scores(
logits=logits,
probs=pred_probs,
rules_contributions=rules_contributions,
class_idx=out_idx
)
if sort_by == "firing_levels":
sorted_indices = torch.argsort(w, descending=True)
elif sort_by == "abs_rules_outputs":
sorted_indices = torch.argsort(torch.abs(consequent_outputs[out_idx, :]), descending=True)
elif sort_by == "rules_outputs":
sorted_indices = torch.argsort(consequent_outputs[out_idx, :], descending=True)
elif sort_by == "abs_contribution":
sorted_indices = torch.argsort(torch.abs(rules_contributions[out_idx, :]), descending=True)
elif sort_by == "contribution":
sorted_indices = torch.argsort(rules_contributions[out_idx, :], descending=True)
elif sort_by == "leave_one_rule_out":
sorted_indices = torch.argsort(I_prob, descending=True)
elif sort_by == "logit_margin":
sorted_indices = torch.argsort(I_logit_margin_max, descending=True)
elif sort_by == "logit_margin_mean":
sorted_indices = torch.argsort(I_logit_margin_mean, descending=True)
else:
raise ValueError(
f"sort_by='{sort_by}' is not a valid option. Use 'firing_levels', 'abs_rules_outputs', 'rules_outputs', 'abs_contribution', 'contribution', 'leave_one_rule_out', 'logit_margin', or 'logit_margin_mean'."
)
if top_k == None:
top_k_indices = sorted_indices[:self.model.rules]
else:
top_k_indices = sorted_indices[:top_k]
rows = []
for idx in top_k_indices:
rid = idx.item()
rows.append({
"rule_id": rid + 1,
"firing_level": w[idx].item(),
"rule_output": consequent_outputs[out_idx, idx].item(),
"contribution": rules_contributions[out_idx, idx].item(),
"I_logit_margin_max": I_logit_margin_max[idx].item(),
"I_logit_margin_mean": I_logit_margin_mean[idx].item(),
"I_prob": I_prob[idx].item(),
})
if self.model._custom_classes:
results_dict[f"class_{self.model._classes[out_idx].item()}"] = pd.DataFrame(rows)
else:
results_dict[f"class_{out_idx}"] = pd.DataFrame(rows)
return results_dict[f"class_{self.model._classes[outputs_to_analyze[0]].item()}"] if len(outputs_to_analyze) == 1 else results_dict
##################################################################
########################### REGRESSION ###########################
##################################################################
else:
for out_idx in outputs_to_analyze:
# sorting
if sort_by == "firing_levels":
sorted_indices = torch.argsort(w, descending=True)
elif sort_by == "abs_rules_outputs":
sorted_indices = torch.argsort(torch.abs(consequent_outputs[out_idx, :]), descending=True)
elif sort_by == "rules_outputs":
sorted_indices = torch.argsort(consequent_outputs[out_idx, :], descending=True)
elif sort_by == "abs_contribution":
sorted_indices = torch.argsort(torch.abs(rules_contributions[out_idx, :]), descending=True)
elif sort_by == "contribution":
sorted_indices = torch.argsort(rules_contributions[out_idx, :], descending=True)
else:
raise ValueError(
f"sort_by='{sort_by}' is not a valid option. Use 'firing_levels', 'abs_rules_outputs', 'rules_outputs', 'abs_contribution', or 'contribution'."
)
if top_k == None:
top_k_indices = sorted_indices[:self.model.rules]
else:
top_k_indices = sorted_indices[:top_k]
rows = []
for idx in top_k_indices:
rid = idx.item()
rows.append({
"rule_id": rid + 1,
"firing_level": w[idx].item(),
"rule_output": consequent_outputs[out_idx, idx].item(),
"contribution": rules_contributions[out_idx, idx].item(),
})
results_dict[f"output_{out_idx}"] = pd.DataFrame(rows)
return results_dict[f"output_{outputs_to_analyze[0]}"] if len(outputs_to_analyze) == 1 else results_dict
[docs]
def explain_prediction(self, x, top_k=None, alpha_cut=0.85, sort_by="firing_levels", show=[], output_idx=None):
"""
Generates a textual explanation of the model's prediction for a given sample, based on the most active rules and their contributions.
Args:
x (torch.Tensor): Input sample to analyze.
top_k (int, optional): Number of top rules to include. If ``None``, all rules are included. Defaults to ``None``.
alpha_cut (float, optional): Minimum membership value used to define the membership intervals for each rule's antecedent. Defaults to ``0.85``.
sort_by (str, optional): Criterion for ranking the active rules. Available options are ``'firing_levels'``, ``'abs_rules_outputs'``, ``'rules_outputs'``, ``'abs_contribution'``, and ``'contribution'``. For models with ``output_type='softmax'``, the additional options ``'leave_one_rule_out'``, ``'logit_margin'``, and ``'logit_margin_mean'`` are also available. Defaults to ``'firing_levels'``.
show (list[str], optional): List of additional metrics to display alongside each rule in classification models. Available options are ``'leave_one_rule_out'``, ``'logit_margin'``, and ``'logit_margin_mean'``. Defaults to ``[]``.
output_idx (int, optional): Index of the output to analyze (0-indexed). Defaults to ``None``.
Returns:
str: Textual explanation of the prediction based on the most active rules and their contributions.
Note:
Regarding ``output_idx``:
- In regression models (``output_type='default'``), it is only used when the model has multiple outputs, to indicate which output the explanation refers to. It is ignored for single-output regression models.
- In classification models (``output_type='softmax'``), it is ignored entirely, as the explanation always focuses on the predicted class.
For available sorting criteria, see :meth:`top_activated_rules`.
"""
x = self._standardize_input(x)
with torch.no_grad():
if self.is_classification:
with torch.no_grad():
probs = self.model(x, return_probs=True)[0] # (C,)
logits = self.model(x, return_probs=False)[0] # (C,)
pred_idx = torch.argmax(probs).item()
pred = self.model.predict(x)
explanation = "=" * 70 + "\n"
explanation += "PREDICTION EXPLANATION\n"
explanation += "=" * 70 + "\n\n"
explanation += f"Predicted class: {pred.item()}\n"
explanation += f"Predicted probability: {probs[pred_idx].item():.4f}\n\n"
explanation += "Logits and probabilities:\n"
for i in range(self.num_outputs):
cname = f"Class {self.model._classes[i]}"
explanation += f" {cname}: logit={logits[i].item():.4f}, p={probs[i].item():.4f}\n"
explanation += "\n"
output_idx = pred_idx
explanation += f"Explaining predicted class: {pred.item()}\n\n"
top_rules = self.top_activated_rules(x, top_k, None, sort_by=sort_by)
explanation += f"Top rules (sorted by {self._get_sort_type_str(sort_by)}):\n"
explanation += "-" * 70 + "\n\n"
key = f"class_{self.model._classes[output_idx].item()}" if self.model._custom_classes else f"class_{output_idx}"
for _, row in top_rules[key].iterrows():
rule_id = row['rule_id'].astype(np.int64)
firing_level = row['firing_level']
rule_output = row['rule_output']
contribution = row['contribution']
I_logit_margin_max = row['I_logit_margin_max']
I_logit_margin_mean = row['I_logit_margin_mean']
I_prob = row['I_prob']
explanation += f"Rule {rule_id} | w={firing_level:.4f} | f(x)={rule_output:.4f} | contrib={contribution:+.4f}"
if sort_by == 'leave_one_rule_out' or 'leave_one_rule_out' in show:
explanation += f" | I_prob={I_prob:+.4f}"
if sort_by == 'logit_margin' or 'logit_margin' in show:
explanation += f" | I_logit_margin_max={I_logit_margin_max:+.4f}"
if sort_by == 'logit_margin_mean' or 'logit_margin_mean' in show:
explanation += f" | I_logit_margin_mean={I_logit_margin_mean:+.4f}"
explanation += "\n"
rule_desc = self._get_rule_description(alpha_cut, rule_id - 1, x)
explanation += f" {rule_desc}\n\n"
return explanation
else:
pred = self.model.predict(x)
explanation = "=" * 70 + "\n"
if self.num_outputs == 1:
explanation += "PREDICTION EXPLANATION\n"
explanation += "=" * 70 + "\n\n"
explanation += f"Prediction: {pred.item():.4f}\n\n"
else:
if output_idx is None:
output_idx = 0
pred = pred.squeeze() # (O,)
explanation += "PREDICTION EXPLANATION (MULTIPLE OUTPUTS)\n"
explanation += "=" * 70 + "\n\n"
explanation += f"Explaining output {output_idx + 1}: {pred[output_idx].item():.4f}\n"
explanation += "\n"
top_rules = self.top_activated_rules(x, top_k, None, sort_by=sort_by)
explanation += f"Top active rules (sorted by {self._get_sort_type_str(sort_by)}):\n"
explanation += "-" * 70 + "\n\n"
if self.num_outputs == 1:
for _, row in top_rules.iterrows():
rule_id = int(row['rule_id'])
firing_level = row['firing_level']
rule_output = row['rule_output']
contribution = row['contribution']
explanation += f"Rule {rule_id} | w={firing_level:.4f} | f(x)={rule_output:.4f} | contrib={contribution:+.4f}\n"
rule_desc = self._get_rule_description(alpha_cut, rule_id - 1, x)
explanation += f" {rule_desc}\n\n"
else:
for _, row in top_rules[f"output_{output_idx}"].iterrows():
rule_id = int(row['rule_id'])
firing_level = row['firing_level']
rule_output = row['rule_output']
contribution = row['contribution']
explanation += f"Rule {rule_id} | w={firing_level:.4f} | f(x)={rule_output:.4f} | contrib={contribution:+.4f}\n"
rule_desc = self._get_rule_description(alpha_cut, rule_id - 1, x, output_idx)
explanation += f" {rule_desc}\n\n"
return explanation
[docs]
def show_fuzzy_sets(self, alpha_cut=0.85):
"""
Generates a textual representation of the fuzzy sets (IF part) of all rules in the model.
Args:
alpha_cut (float, optional): Minimum membership value used to define the membership intervals. Defaults to ``0.85``.
Returns:
str: Text containing the antecedents of all rules in the model.
Note:
This method does not depend on a specific input sample. Its purpose is to display the global fuzzy structure of the model, showing the fuzzy sets associated with each rule based on their alpha-cuts.
"""
output = "=" * 70 + "\n"
output += "MODEL FUZZY SETS\n"
output += "=" * 70 + "\n\n"
output += f"Total rules: {self.model.rules}\n\n"
for rule_idx in range(self.model.rules):
if_clause = self._get_rule_if_clause(alpha_cut, rule_idx)
output += f"Rule {rule_idx + 1}:\n"
output += f" {if_clause}\n\n"
return output
[docs]
def show_top_fuzzy_sets(self, x, top_k=None, alpha_cut=0.85, sort_by="firing_levels", show=[], output_idx=None):
"""
Generates a textual representation of the antecedents (IF part) of the
most relevant rules for a given input sample.
Args:
x (torch.Tensor): Input sample to analyze.
top_k (int, optional): Number of top rules to include. If ``None``, all rules are included. Defaults to ``None``.
alpha_cut (float, optional): Minimum membership value used to define the membership intervals. Defaults to ``0.85``.
sort_by (str, optional): Criterion for ranking the active rules. Available options are ``'firing_levels'``, ``'abs_rules_outputs'``, ``'rules_outputs'``, ``'abs_contribution'``, and ``'contribution'``. For models with ``output_type='softmax'``, the additional options ``'leave_one_rule_out'``, ``'logit_margin'``, and ``'logit_margin_mean'`` are also available. Defaults to ``'firing_levels'``.
show (list[str], optional): List of additional metrics to display alongside each rule in classification models. Available options are ``'leave_one_rule_out'``, ``'logit_margin'``, and ``'logit_margin_mean'``. Defaults to ``[]``.
output_idx (int, optional): Index of the output to analyze (0-indexed) in regression models with multiple outputs. If ``None``, the first output is analyzed. Ignored in classification models, where the explanation always focuses on the predicted class. Defaults to ``None``.
Returns:
str: Text containing the antecedents (IF part) of the most relevant rules.
Note:
This method reuses the ranking criterion of :meth:`top_activated_rules` and is intended as a compact version
of :meth:`explain_prediction`, showing only the fuzzy sets activated in the rule antecedents.
"""
x = self._standardize_input(x)
with torch.no_grad():
if self.is_classification:
probs = self.model(x, return_probs=True)[0] # (C,)
logits = self.model(x, return_probs=False)[0] # (C,)
pred_idx = torch.argmax(probs).item()
pred = self.model.predict(x)
explanation = "=" * 70 + "\n"
explanation += "TOP FUZZY SETS\n"
explanation += "=" * 70 + "\n\n"
explanation += f"Predicted class: {pred.item()}\n"
explanation += f"Predicted probability: {probs[pred_idx].item():.4f}\n\n"
explanation += "Logits and probabilities:\n"
for i in range(self.num_outputs):
cname = f"Class {self.model._classes[i]}"
explanation += f" {cname}: logit={logits[i].item():.4f}, p={probs[i].item():.4f}\n"
explanation += "\n"
output_idx = pred_idx
explanation += f"Showing antecedents for predicted class: {pred.item()}\n\n"
top_rules = self.top_activated_rules(x, top_k, None, sort_by=sort_by)
explanation += f"Fuzzy sets of the most activated rules (sorted by {self._get_sort_type_str(sort_by)}):\n"
explanation += "-" * 70 + "\n\n"
key = f"class_{self.model._classes[output_idx].item()}" if self.model._custom_classes else f"class_{output_idx}"
for _, row in top_rules[key].iterrows():
rule_id = int(row['rule_id'])
firing_level = row['firing_level']
rule_output = row['rule_output']
contribution = row['contribution']
I_logit_margin_max = row['I_logit_margin_max']
I_logit_margin_mean = row['I_logit_margin_mean']
I_prob = row['I_prob']
explanation += f"Rule {rule_id} | w={firing_level:.4f} | f(x)={rule_output:.4f} | contrib={contribution:+.4f}"
if sort_by == 'leave_one_rule_out' or 'leave_one_rule_out' in show:
explanation += f" | I_prob={I_prob:+.4f}"
if sort_by == 'logit_margin' or 'logit_margin' in show:
explanation += f" | I_logit_margin_max={I_logit_margin_max:+.4f}"
if sort_by == 'logit_margin_mean' or 'logit_margin_mean' in show:
explanation += f" | I_logit_margin_mean={I_logit_margin_mean:+.4f}"
explanation += "\n"
if_clause = self._get_rule_if_clause(alpha_cut, rule_id - 1)
explanation += f" {if_clause}\n\n"
return explanation
else:
pred = self.model.predict(x)
explanation = "=" * 70 + "\n"
if self.num_outputs == 1:
explanation += "TOP FUZZY SETS\n"
explanation += "=" * 70 + "\n\n"
explanation += f"Prediction: {pred.item():.4f}\n\n"
else:
if output_idx is None:
output_idx = 0
pred = pred.squeeze()
explanation += "TOP FUZZY SETS (MULTIPLE OUTPUTS)\n"
explanation += "=" * 70 + "\n\n"
explanation += f"Explaining output {output_idx + 1}: {pred[output_idx].item():.4f}\n\n"
top_rules = self.top_activated_rules(x, top_k, None, sort_by=sort_by)
explanation += f"Fuzzy sets of the most activated rules (sorted by {self._get_sort_type_str(sort_by)}):\n"
explanation += "-" * 70 + "\n\n"
if self.num_outputs == 1:
for _, row in top_rules.iterrows():
rule_id = int(row['rule_id'])
firing_level = row['firing_level']
rule_output = row['rule_output']
contribution = row['contribution']
explanation += f"Rule {rule_id} | w={firing_level:.4f} | f(x)={rule_output:.4f} | contrib={contribution:+.4f}\n"
if_clause = self._get_rule_if_clause(alpha_cut, rule_id - 1)
explanation += f" {if_clause}\n\n"
else:
for _, row in top_rules[f"output_{output_idx}"].iterrows():
rule_id = int(row['rule_id'])
firing_level = row['firing_level']
rule_output = row['rule_output']
contribution = row['contribution']
explanation += f"Rule {rule_id} | w={firing_level:.4f} | f(x)={rule_output:.4f} | contrib={contribution:+.4f}\n"
if_clause = self._get_rule_if_clause(alpha_cut, rule_id - 1)
explanation += f" {if_clause}\n\n"
return explanation
def _get_rule_if_clause(self, alpha_cut, rule_idx):
"""
Generates the IF clause (antecedent) of a single rule.
Args:
alpha_cut (float): Minimum membership value used to define the membership intervals.
rule_idx (int): Index of the rule to describe (0-indexed).
Returns:
str: Antecedent of the rule in human-readable format.
Raises:
ValueError: If ``rule_idx`` is out of range.
Note:
The antecedent is constructed from the membership function parameters of each input feature and their alpha-cut intervals.
"""
premises = self.model.get_premises()
if rule_idx >= self.model.rules:
raise ValueError(
f"rule_idx={rule_idx} is out of range. The model has {self.model.rules} rules."
)
if_parts = []
# ANFIS / h_ANFIS sin reducción de reglas
if isinstance(self.model, ANFIS) or (isinstance(self.model, h_ANFIS) and not self.model._rule_reduced):
if isinstance(self.model, ANFIS):
mf_dist = self.model._fuzzification_layer._mf_distribution
else:
mf_dist = [self.model.num_mfs] * self.model._input_size
mf_indices = []
temp_idx = rule_idx
for i in range(len(mf_dist) - 1, -1, -1):
mf_indices.insert(0, temp_idx % mf_dist[i])
temp_idx //= mf_dist[i]
for input_idx, (premise, mf_idx) in enumerate(zip(premises, mf_indices)):
params = []
i = 0
for _ in self.model._fuzzification_layer._membership_function._params:
params.append(premise[mf_idx, i].item())
i += 1
range_np = self.model._fuzzification_layer._membership_function._simple_alpha_cut(alpha_cut, *params)
range_str = f"∈ [{range_np[0].item():.2f}, {range_np[1].item():.2f}]"
feature_name = self.model.features[input_idx]
if_parts.append(f"{feature_name} {range_str}")
# rule_reduced_ANFIS
else:
for input_idx, premise in enumerate(premises):
params = []
i = 0
for _ in self.model._fuzzification_layer._membership_function._params:
params.append(premise[rule_idx, i].item())
i += 1
range_np = self.model._fuzzification_layer._membership_function._simple_alpha_cut(alpha_cut, *params)
range_str = f"∈ [{range_np[0].item():.2f}, {range_np[1].item():.2f}]"
feature_name = self.model.features[input_idx]
if_parts.append(f"{feature_name} {range_str}")
if_clause = " AND ".join(if_parts) if if_parts else "N/A"
return f"IF {if_clause}"
def _get_rule_description(self, alpha_cut, rule_idx, x, output_idx=None):
"""
Generates a full IF-THEN textual description of a specific rule.
Note:
The docstring of this method should be placed before the first line of code. Currently it appears after ``x = self._standardize_input(x)``.
Args:
alpha_cut (float): Minimum membership value used to define the membership intervals.
rule_idx (int): Index of the rule to describe (0-indexed).
x (torch.Tensor): Input sample for which the rule is described.
output_idx (int, optional): Index of the output to analyze (0-indexed) in regression models with multiple outputs. If ``None``, the first output is analyzed. Ignored in classification models and single-output regression models. Defaults to ``None``.
Returns:
str: Full IF-THEN textual description of the rule.
Raises:
ValueError: If ``rule_idx`` is out of range.
"""
x = self._standardize_input(x)
premises = self.model.get_premises()
consequents = self.model.get_consequents()
if rule_idx >= self.model.rules:
raise ValueError(f"rule_idx={rule_idx} is out of range. The model has {self.model.rules} rules.")
##################################################################
############################### IF ###############################
##################################################################
if_parts = []
# ANFIS
if isinstance(self.model, ANFIS) or (isinstance(self.model, h_ANFIS) and not self.model._rule_reduced):
# Necesitamos mapear el índice de regla a las combinaciones de MFs
if isinstance(self.model, ANFIS):
mf_dist = self.model._fuzzification_layer._mf_distribution
else:
mf_dist = [self.model.num_mfs]*self.model._input_size
# Se hace un proceso de división sucesiva para mapear el índice de regla a los índices de MFs correspondientes a cada input
mf_indices = []
temp_idx = rule_idx
for i in range(len(mf_dist) - 1, -1, -1):
mf_indices.insert(0, temp_idx % mf_dist[i])
temp_idx //= mf_dist[i]
for input_idx, (premise, mf_idx) in enumerate(zip(premises, mf_indices)):
params = []
i = 0
for _ in self.model._fuzzification_layer._membership_function._params:
params.append(premise[mf_idx, i].item())
i += 1
range_np = self.model._fuzzification_layer._membership_function._simple_alpha_cut(alpha_cut, *params)
range_str = f"∈ [{range_np[0].item():.2f}, {range_np[1].item():.2f}]"
feature_name = self.model.features[input_idx]
if_parts.append(f"{feature_name} {range_str}")
# rule_reduced_ANFIS
else:
for input_idx, premise in enumerate(premises):
params = []
i = 0
for _ in self.model._fuzzification_layer._membership_function._params:
params.append(premise[rule_idx, i].item())
i += 1
range_np = self.model._fuzzification_layer._membership_function._simple_alpha_cut(alpha_cut, *params)
range_str = f"∈ [{range_np[0].item():.2f}, {range_np[1].item():.2f}]"
feature_name = self.model.features[input_idx]
if_parts.append(f"{feature_name} {range_str}")
if_clause = " AND ".join(if_parts) if if_parts else "N/A"
##################################################################
############################## THEN ##############################
##################################################################
then_parts = {}
for temp_output_idx in range(self.num_outputs):
coefs = consequents[temp_output_idx, rule_idx, :-1]
bias = consequents[temp_output_idx, rule_idx, -1]
terms = []
for i, coef in enumerate(coefs):
if abs(coef.item()) > 1e-6:
terms.append(f"{coef.item():.3f}*{self.model.features[i]}")
terms.append(f"{bias.item():.3f}")
then_expr = " + ".join(terms).replace("+ -", "- ")
if self.is_classification:
cname = self.model._classes[temp_output_idx]
out_name = f"f_{cname}(x)"
then_parts[temp_output_idx] = [out_name, then_expr]
else:
out_name = f"output_{temp_output_idx + 1}"
then_parts[temp_output_idx] = [out_name, then_expr]
##################################################################
############################## RULE ##############################
##################################################################
rule = ""
if_part_len = len(if_clause) + 11 # " IF " + if_clause + " THEN "
if self.is_classification:
rule = f"IF {if_clause} THEN "
for i in range(self.num_outputs):
rule += f"{then_parts[i][0]} = {then_parts[i][1]} \n"
rule += " " * if_part_len
else:
if output_idx is None:
output_idx = 0
rule = f"IF {if_clause} THEN f(x) = {then_parts[output_idx][1]}"
return rule
def _get_sort_type_str(self, sort_by):
"""
Returns a human-readable description of a sorting criterion given its code.
Args:
sort_by (str): Sorting criterion code. Available options are ``'firing_levels'``, ``'abs_rules_outputs'``, ``'rules_outputs'``, ``'abs_contribution'``, and ``'contribution'``. For classification models, the additional options ``'leave_one_rule_out'``, ``'logit_margin'``, and ``'logit_margin_mean'`` are also available.
Returns:
str: Human-readable description of the sorting criterion.
"""
sort_types = {
'firing_levels': "firing levels",
'abs_rules_outputs': "absolute values of unweighted rule outputs",
'rules_outputs': "unweighted rule outputs",
}
if self.is_classification:
sort_types['abs_contribution'] = "absolute contribution to the class logit"
sort_types['contribution'] = "contribution to the class logit"
sort_types['leave_one_rule_out'] = "change in predicted class probability when the rule is removed"
sort_types['logit_margin'] = "logit margin (predicted class vs. highest-scoring alternative)"
sort_types['logit_margin_mean'] = "logit margin (predicted class vs. mean of other classes)"
else:
sort_types['abs_contribution'] = "absolute contribution to the final output"
sort_types['contribution'] = "contribution to the final output"
return sort_types[sort_by]