Source code for peal.teachers.symbolic_teacher
"""Teacher for tabular data with a known symbolic confounder feature.
Teachers are PEAL's sources of feedback on counterfactuals. This one needs no
human or oracle image model: it works on feature vectors whose attributes are
listed in ``dataset.attributes`` and decides whether a counterfactual is a
genuine explanation or one that merely toggled the named confounder column.
"""
import torch
from peal.teachers.interfaces import TeacherInterface
[docs]
class SymbolicTeacher(TeacherInterface):
"""Teacher that judges tabular counterfactuals by reverting one confounder.
For every counterfactual the confounder column is set back to its original
value; if that alone restores the reference model's original prediction the
class change was driven solely by the confounder and the counterfactual is
flagged ``"false"``, otherwise ``"true"``.
Parameters
----------
model : torch.nn.Module
Reference classifier (the "teacher" model) mapping a feature vector of
shape ``(num_features,)`` to class logits. Its device determines
``self.device``.
dataset
Dataset exposing ``attributes``, the ordered list of feature names.
confounder_name : str
Name of the confounder feature; must be in ``dataset.attributes``.
tracking_level : int, optional
Stored only; not used by this teacher.
counterfactual_type : str, optional
``"1sided"`` (default) additionally rejects samples the student already
misclassified before the edit.
Attributes
----------
confounder_idx : int
Column index of the confounder inside the feature vector.
Raises
------
ValueError
If ``confounder_name`` is not among the dataset attributes.
"""
[docs]
def __init__(
self,
model,
dataset,
confounder_name: str,
tracking_level=0,
counterfactual_type="1sided",
):
"""Resolve the confounder column index and the reference model's device.
Raises
------
ValueError
If ``confounder_name`` is not in ``dataset.attributes``.
"""
self.model = model
self.dataset = dataset
self.confounder_name = confounder_name
self.tracking_level = tracking_level
self.counterfactual_type = counterfactual_type
self.device = "cuda" if next(self.model.parameters()).is_cuda else "cpu"
# Identify the index of the confounder
features = self.dataset.attributes
if self.confounder_name in features:
self.confounder_idx = features.index(self.confounder_name)
else:
raise ValueError(
f"Confounder '{self.confounder_name}' not found in dataset attributes: {features}"
)
[docs]
def get_feedback(
self,
x_counterfactual_list,
y_source_list,
x_list,
y_list,
y_target_end_confidence_list,
base_dir=None,
y_target_list=None,
student=None,
mode="train",
**kwargs,
):
"""Label every counterfactual as ``"true"``, ``"false"`` or a rejection.
Parameters
----------
x_counterfactual_list : sequence of torch.Tensor
Counterfactual feature vectors, each of shape ``(num_features,)``.
y_source_list : sequence of int
Class predicted by the student for the original sample.
x_list : sequence of torch.Tensor
Original feature vectors matching ``x_counterfactual_list``.
y_list : sequence of int
Ground-truth labels of the originals.
y_target_end_confidence_list : sequence of float
Student confidence in the target class after the edit.
base_dir : optional
Unused; kept for interface compatibility.
y_target_list : sequence of int, optional
Target class of every counterfactual.
student : torch.nn.Module
The classifier being repaired; required despite the default.
mode : str, optional
Unused; kept for interface compatibility.
Returns
-------
list of str
One entry per counterfactual, in order of precedence:
``"student originally wrong!"`` (1-sided mode, student label differs
from ground truth), ``"teacher originally wrong!"``,
``"student not swapped!"`` (confidence below 0.5),
``"adversarial counterfactual!"`` (student flipped but the edit did
not reach ``y_target``), ``"not flipped"`` (teacher model keeps its
class), ``"false"`` (flip explained by the confounder alone) or
``"true"``.
Notes
-----
The model is switched to eval mode for the duration of the call and
restored afterwards.
"""
feedback = []
is_train = self.model.training
self.model.eval()
for idx, cf in enumerate(x_counterfactual_list):
original_x = x_list[idx]
# 1. Model prediction on original
with torch.no_grad():
pred_original = (
self.model(original_x.unsqueeze(0).to(self.device))
.squeeze(0)
.cpu()
.argmax(-1)
.item()
)
# 2. Model prediction on counterfactual
pred_cf = (
self.model(cf.unsqueeze(0).to(self.device))
.squeeze(0)
.cpu()
.argmax(-1)
.item()
)
student_pred_original = (
student(original_x.unsqueeze(0).to(self.device))
.squeeze(0)
.cpu()
.argmax(-1)
.item()
)
student_pred_cf = (
student(cf.unsqueeze(0).to(self.device))
.squeeze(0)
.cpu()
.argmax(-1)
.item()
)
if (
self.counterfactual_type == "1sided"
and y_list[idx] != y_source_list[idx]
):
feedback.append("student originally wrong!")
elif pred_original != y_list[idx]:
feedback.append("teacher originally wrong!")
elif y_target_end_confidence_list[idx] < 0.5:
feedback.append("student not swapped!")
elif (
student_pred_original == y_source_list[idx]
and student_pred_cf != y_target_list[idx]
):
feedback.append("adversarial counterfactual!")
else:
if pred_original == pred_cf:
# Counterfactual didn't even flip the class for the teacher
feedback.append("not flipped")
else:
# It flipped the class! Let's check why.
# We flip the confounder back to the value it had in the original image.
cf_reverted = cf.clone()
cf_reverted[self.confounder_idx] = original_x[self.confounder_idx]
with torch.no_grad():
pred_cf_reverted = (
self.model(cf_reverted.unsqueeze(0).to(self.device))
.squeeze(0)
.cpu()
.argmax(-1)
.item()
)
# If reverting the confounder reverts the prediction back to the original class,
# it means the change in prediction was SOLELY driven by the confounder.
if pred_cf_reverted == pred_original:
feedback.append("false")
else:
feedback.append("true")
if is_train:
self.model.train()
return feedback