IDS-Multi-Agents-Graph-RAG / utils /LocalModelStability.py
tarekfer8's picture
Upload folder using huggingface_hub
bf8df4f verified
Raw
History Blame Contribute Delete
13.9 kB
import numpy as np
import torch
class LocalModelStability:
def __init__(self, model, reference_data, feature_specs, device="cpu"):
"""
model : PyTorch model; model(x, apply_activation=False) returns logits
reference_data : numpy array (n_rows, n_columns), one-hot encoded
feature_specs : list of dicts, each with:
- 'name': str
- 'type': 'numerical' | 'ordinal_group' | 'categorical_group'
- 'columns': list of column indices
- 'decoded_values': list of ints (ordinal_group only)
device : 'cpu' or 'cuda'
"""
self.model = model.to(device).eval()
self.device = device
self.reference_data = np.asarray(reference_data, dtype=np.float32)
self.feature_specs = feature_specs
self._spec_by_name = {spec["name"]: spec for spec in feature_specs}
# Pre-decode ordinal groups
self._decoded_ordinals = {}
for spec in feature_specs:
if spec["type"] == "ordinal_group":
cols = spec["columns"]
values = np.asarray(spec["decoded_values"])
block = self.reference_data[:, cols] # (n_rows, group_size)
active_col = np.argmax(block, axis=1) # (n_rows,)
has_active = block.sum(axis=1) > 0 # (n_rows,) bool
decoded = np.where(has_active, values[active_col], np.iinfo(np.int64).min)
self._decoded_ordinals[spec["name"]] = decoded
# Pre-compute reference predictions (batched)
self._reference_predictions = self._batch_predict(self.reference_data)
# ------------------------------------------------------------
# Prediction
# ------------------------------------------------------------
def _batch_predict(self, X, batch_size=1024):
preds = []
with torch.no_grad():
for i in range(0, len(X), batch_size):
batch = torch.tensor(X[i:i + batch_size], dtype=torch.float32).to(self.device)
out = self.model(batch, apply_activation=False).cpu().numpy().reshape(-1)
preds.append(out)
return np.concatenate(preds)
def predict(self, x):
"""Return logit(s) for input x (1D or 2D array)."""
x = np.atleast_2d(np.asarray(x, dtype=np.float32))
return self._batch_predict(x)
# ------------------------------------------------------------
# Neighborhood building
# ------------------------------------------------------------
def build_neighborhood(self, x0, feature_name, n_max, rng=None):
if rng is None:
rng = np.random.default_rng()
x0 = np.asarray(x0, dtype=np.float32).reshape(-1)
spec = self._spec_by_name[feature_name]
ftype = spec["type"]
if ftype == "numerical":
return self._build_numerical(x0, spec, n_max, rng)
elif ftype == "ordinal_group":
return self._build_ordinal(x0, spec, n_max, rng)
elif ftype == "categorical_group":
return self._build_categorical(x0, spec, n_max, rng)
else:
raise ValueError(f"Unknown feature type: {ftype}")
# ---- Numerical ----
def _build_numerical(self, x0, spec, n_max, rng):
col = spec["columns"][0]
target = x0[col]
feature_values = self.reference_data[:, col]
exact_mask = feature_values == target
exact_idx = np.where(exact_mask)[0]
n_exact = len(exact_idx)
if n_exact >= n_max:
# Take ALL exact matches (no subsampling)
selected = exact_idx
else:
# Fill up to n_max with nearest neighbors
remaining = n_max - n_exact
non_exact_idx = np.where(~exact_mask)[0]
if len(non_exact_idx) == 0:
selected = exact_idx
else:
distances = np.abs(feature_values[non_exact_idx] - target)
k = min(remaining, len(non_exact_idx))
nearest = non_exact_idx[np.argpartition(distances, k - 1)[:k]]
selected = np.concatenate([exact_idx, nearest])
return self._package_result(selected, n_exact, "ok")
# ---- Ordinal group ----
def _build_ordinal(self, x0, spec, n_max, rng):
cols = spec["columns"]
values = np.asarray(spec["decoded_values"])
x0_group = x0[cols]
if x0_group.sum() == 0:
return self._empty_result("no_active_category")
target_value = values[int(np.argmax(x0_group))]
decoded_ref = self._decoded_ordinals[spec["name"]]
# Exclude rows with no active category
valid_mask = decoded_ref != np.iinfo(np.int64).min
valid_idx = np.where(valid_mask)[0]
valid_values = decoded_ref[valid_idx]
exact_mask = valid_values == target_value
exact_idx = valid_idx[exact_mask]
n_exact = len(exact_idx)
if n_exact >= n_max:
# Take ALL exact matches (no subsampling)
selected = exact_idx
else:
# Exact matches insufficient — fill up to n_max with nearest neighbors
remaining = n_max - n_exact
non_exact_idx = valid_idx[~exact_mask]
if len(non_exact_idx) == 0:
selected = exact_idx
else:
distances = np.abs(decoded_ref[non_exact_idx] - target_value)
k = min(remaining, len(non_exact_idx))
nearest = non_exact_idx[np.argpartition(distances, k - 1)[:k]]
selected = np.concatenate([exact_idx, nearest])
return self._package_result(selected, n_exact, "ok")
# ---- Categorical group ----
def _build_categorical(self, x0, spec, n_max, rng):
cols = spec["columns"]
x0_group = x0[cols]
if x0_group.sum() == 0:
return self._empty_result("no_active_category")
active_col_in_group = int(np.argmax(x0_group))
active_col_global = cols[active_col_in_group]
match_mask = self.reference_data[:, active_col_global] == 1.0
match_idx = np.where(match_mask)[0]
n_matches = len(match_idx)
if n_matches == 0:
return self._empty_result("empty")
selected = match_idx # take all exact matches
return self._package_result(selected, n_matches if n_matches < n_max else n_max, "ok")
# ------------------------------------------------------------
# Helpers
# ------------------------------------------------------------
def _package_result(self, indices, n_exact, status):
return {
"indices": indices,
"predictions": self._reference_predictions[indices],
"n_selected": len(indices),
"n_exact_matches": int(n_exact),
"status": status,
}
def _empty_result(self, status):
return {
"indices": None,
"predictions": None,
"n_selected": 0,
"n_exact_matches": 0,
"status": status,
}
# ------------------------------------------------------------
# Metric computation
# ------------------------------------------------------------
def compute_metric(self, neighborhood_result, z0, metric="mse",
tau_min=0.01, tau_max=0.5, n_thresholds=20):
"""
Compute a per-feature stability metric from a neighborhood result.
Parameters
----------
neighborhood_result : dict
Output of build_neighborhood for a single feature.
z0 : float
Reference prediction for the instance being explained.
metric : {'mse', 'auc'}
- 'mse': mean squared difference between neighbor predictions and z0
(lower = more stabilizing).
- 'auc': area under the relative-proximity curve across tau thresholds
(higher = more stabilizing).
tau_min, tau_max, n_thresholds : floats/int
Used only when metric='auc'.
Returns
-------
float or None
Metric value, or None if the neighborhood is empty / invalid.
"""
if neighborhood_result["status"] != "ok" or neighborhood_result["predictions"] is None:
return None
z = neighborhood_result["predictions"]
z0 = float(z0)
if metric == "mse":
return float(np.mean((z - z0) ** 2))
elif metric == "auc":
denom = max(abs(z0), 1e-10)
rel_diff = np.abs(z - z0) / denom
taus = np.linspace(tau_min, tau_max, n_thresholds)
prox = (rel_diff[:, None] <= taus[None, :]).mean(axis=0) # (n_thresholds,)
return float(np.trapezoid(prox, x=taus))
else:
raise ValueError(f"Unknown metric: {metric}. Use 'mse' or 'auc'.")
# ------------------------------------------------------------
# Convenience: neighborhoods + metrics for a single instance
# ------------------------------------------------------------
def explain_instance(self, x0, n_max=1000, metric="mse",
tau_min=0.01, tau_max=0.5, n_thresholds=20, rng=None):
"""
Build neighborhoods and compute the chosen metric for every feature
of a single instance.
Parameters
----------
x0 : array-like
The instance to explain (1D, length = n_columns).
n_max : int
Target neighborhood size.
metric : {'mse', 'auc'}
Metric to compute per feature.
tau_min, tau_max, n_thresholds :
Passed to compute_metric (used only for 'auc').
rng : np.random.Generator, optional
Shared RNG for reproducibility across features.
Returns
-------
dict
{
'z0': float,
'neighborhoods': {feature_name: neighborhood_result_dict},
'metrics': {feature_name: float or None},
}
"""
x0 = np.asarray(x0, dtype=np.float32).reshape(-1)
z0 = float(self.predict(x0)[0])
neighborhoods = {}
metrics = {}
for spec in self.feature_specs:
fname = spec["name"]
r = self.build_neighborhood(x0, fname, n_max=n_max, rng=rng)
neighborhoods[fname] = r
metrics[fname] = self.compute_metric(
r, z0,
metric=metric,
tau_min=tau_min,
tau_max=tau_max,
n_thresholds=n_thresholds,
)
return {
"z0": z0,
"neighborhoods": neighborhoods,
"metrics": metrics,
}
import pandas as pd
import numpy as np
import joblib
# Required so joblib can resolve the custom function stored in the pipeline
from utils.preprocessing_utils import log1p_base10 # noqa: F401
def load_processed_data(
attack,
benign_data_path="Data/benign_only/all_days_benign.csv",
attack_data_dir="Data/attacks_only",
preprocessing_dir="_prepcosessing_artefacts/",
model_dir_template="checkpoints_MLP/{attack}/",
seed=42,
):
model_dir = model_dir_template.format(attack=attack)
# Load preprocessing artifacts
ohe = joblib.load(preprocessing_dir + "onehot_encoder.pkl")
schema = joblib.load(preprocessing_dir + "column_schema.pkl")
numeric_pipeline = joblib.load(model_dir + "numeric_pipeline.pkl")
categorical_cols = schema["categorical_cols"]
numerical_cols = schema["numerical_cols"]
ohe_feature_names = schema["ohe_feature_names"]
# Read raw CSVs
df_benign = pd.read_csv(benign_data_path)
df_attack = pd.read_csv(f"{attack_data_dir}/{attack}.csv")
# One-hot encode categoricals
benign_cat = pd.DataFrame(
ohe.transform(df_benign[categorical_cols]),
columns=ohe_feature_names,
index=df_benign.index,
)
attack_cat = pd.DataFrame(
ohe.transform(df_attack[categorical_cols]),
columns=ohe_feature_names,
index=df_attack.index,
)
# Concatenate numerical + categorical
df_benign_proc = pd.concat(
[df_benign[numerical_cols].reset_index(drop=True),
benign_cat.reset_index(drop=True)],
axis=1,
)
df_attack_proc = pd.concat(
[df_attack[numerical_cols].reset_index(drop=True),
attack_cat.reset_index(drop=True)],
axis=1,
)
# Filter invalid rows (negatives or non-finite values in numerical columns)
df_benign_proc = df_benign_proc[
(df_benign_proc[numerical_cols] >= 0).all(axis=1)
& np.isfinite(df_benign_proc[numerical_cols]).all(axis=1)
].copy()
df_attack_proc = df_attack_proc[
(df_attack_proc[numerical_cols] >= 0).all(axis=1)
& np.isfinite(df_attack_proc[numerical_cols]).all(axis=1)
].copy()
# Add labels and merge
df_benign_proc["label"] = 0
df_attack_proc["label"] = 1
df_full = pd.concat([df_benign_proc, df_attack_proc], axis=0, ignore_index=True)
df_full = df_full.sample(frac=1, random_state=seed).reset_index(drop=True)
# Split features and labels
X = df_full.drop(columns=["label"])
y = df_full["label"]
# Apply numeric pipeline (log1p + min-max)
X_num = pd.DataFrame(
numeric_pipeline.transform(X[numerical_cols]),
columns=numerical_cols,
index=X.index,
)
X_final = pd.concat([X_num, X[ohe_feature_names]], axis=1)
return {
"X_final": X_final,
"y": y,
"schema": schema,
}