Repository: AI-SDC/SACRO-ML (language: python)

Issue:
Programmatic attack running
Both MIA attack types should adhere to the same interface and produce output in the same format. This will mean that they can be called from within the wrappers easily.

Decision for initial discussions:
1. Attacks should use dataset class (see attack_utils in GM repo)
2. Superclass for all attacks should be defined (not clear atm how much functionality will go in here)
3. SR to make WorstCase and LiRa code conform to this and then RP will do the same for AIA
4. Some work will be needed on aligning output but this can wait until all attacks have been implemented

@jim-smith , @rpreen , @albacrespi -- does this match your recollection (sorry I had to disappear so quick -- home office sharer had job interview)
should be a fix for the multi-class LIRA problem
Closes #44 
@jim-smith could you see if this change fixes the problem you were having with examples in the test data for classes that weren't in the train data please?
If so, we can merge it in.
Solution I went for:
1. the sklearn models have a `_classes` attribute which tells you which column in the predicted probabilities correspond to each class. This is normally just `[0, 1, 2, ...]` but if, say, a class was not present in the training data it might be `[0, 2, 3]` say. Which would screw up LIRA.
2. I've re-created the class values in the dataset object so that the value in y _will_ correspond to a column in the probabilities
3. Removed any test example that have a class label that is not in `_classes`


Discussion / hints:
Yes, that sounds about right. Maybe the data_precessing should be included so there is a working example?
________________________________
From: simonrnss ***@***.***>
Sent: 23 August 2022 16:25
To: AI-SDC/AI-SDC ***@***.***>
Cc: Alba Crespi Boixader (Staff) ***@***.***>; Mention ***@***.***>
Subject: [AI-SDC/AI-SDC] Programmatic attack running (Issue #30)




CAUTION: This email originated from outside the University of Dundee. Do not click links or open attachments unless you recognise the sender's email address and know the content is safe.

Both MIA attack types should adhere to the same interface and produce output in the same format. This will mean that they can be called from within the wrappers easily.

Decision for initial discussions:

  1.  Attacks should use dataset class (see attack_utils in GM repo)
  2.  Superclass for all attacks should be defined (not clear atm how much functionality will go in here)
  3.  SR to make WorstCase and LiRa code conform to this and then RP will do the same for AIA
  4.  Some work will be needed on aligning output but this can wait until all attacks have been implemented

@jim-smith<https://github.com/jim-smith> , @rpreen<https://github.com/rpreen> , @albacrespi<https://github.com/albacrespi> -- does this match your recollection (sorry I had to disappear so quick -- home office sharer had job interview)

—
Reply to this email directly, view it on GitHub<https://github.com/AI-SDC/AI-SDC/issues/30>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/ADXK2CT7NNKLYNREFIZPRDTV2TUPZANCNFSM562EEYNQ>.
You are receiving this because you were mentioned.Message ID: ***@***.***>

The University of Dundee is a registered Scottish Charity, No: SC015096

We will have worked examples for the individual attacks, yep (already do, but I'll convert things into this new format).
We can also then have a worked example of safe wrappers *and* attacks being called by the wrappers
ahhh, I meant to tag @mccarthyajb , not @albacrespi (I jumped into a UWE meeting today to discuss - which was the discussion), but always good to get your approval too @albacrespi ! :-)


Fix patch:
diff --git a/.pylintrc b/.pylintrc
index 95e36366..5b9b1d0b 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -57,7 +57,7 @@ ignore-patterns=^\.#
 # (useful for modules/projects where namespaces are manipulated during runtime
 # and thus existing member attributes cannot be deduced by static analysis). It
 # supports qualified module names, as well as Unix pattern matching.
-ignored-modules=
+ignored-modules=multiprocess,sklearn.datasets
 
 # Python code to execute, usually for sys.path manipulation such as
 # pygtk.require().
@@ -557,7 +557,11 @@ ignored-checks-for-mixins=no-member,
 # List of class names for which member attributes should not be checked (useful
 # for classes with dynamically set attributes). This supports the use of
 # qualified names.
-ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
+ignored-classes=optparse.Values,
+                thread._local,
+                _thread._local,
+                argparse.Namespace,
+                attacks.worst_case_attack.WorstCaseAttackArgs
 
 # Show a hint with possible names when a member name was not found. The aspect
 # of finding the hint is based on edit distance.
diff --git a/attacks/attack.py b/attacks/attack.py
new file mode 100644
index 00000000..435416ae
--- /dev/null
+++ b/attacks/attack.py
@@ -0,0 +1,14 @@
+"""attack.py - base class for an attack object"""
+
+import sklearn
+from attacks.dataset import Data
+
+
+class Attack:
+    """Base (abstract) class to represent an attack"""
+    def attack(self, dataset: Data, target_model: sklearn.base.BaseEstimator) -> None:
+        """Method to run an attack"""
+        raise NotImplementedError
+
+    def __str__(self):
+        raise NotImplementedError
diff --git a/attacks/attribute_attack.py b/attacks/attribute_attack.py
new file mode 100644
index 00000000..68a71c27
--- /dev/null
+++ b/attacks/attribute_attack.py
@@ -0,0 +1,613 @@
+"""
+Attribute inference attacks.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import pickle
+from typing import Any
+
+import matplotlib.pyplot as plt
+import multiprocess as mp
+import numpy as np
+from fpdf import FPDF
+from sklearn.base import BaseEstimator
+from sklearn.preprocessing import OneHotEncoder
+
+from attacks import report
+from attacks.attack import Attack
+from attacks.dataset import Data
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger("aia")
+
+COLOR_A: str = "#86bf91"  # training set plot colour
+COLOR_B: str = "steelblue"  # testing set plot colour
+
+
+class AttributeAttackArgs:
+    """Arguments for attribute inference."""
+
+    def __init__(self, **kwargs):
+        self.__dict__["report_name"] = None
+        self.__dict__["n_cpu"] = max(1, mp.cpu_count() - 1)
+        self.__dict__.update(kwargs)
+
+    def __str__(self):
+        return ",".join(
+            [f"{str(key)}: {str(value)}" for key, value in self.__dict__.items()]
+        )
+
+    def set_param(self, key: str, value: Any) -> None:
+        """Set a parameter"""
+        self.__dict__[key] = value
+
+    def get_args(self) -> dict:
+        """Return arguments"""
+        return self.__dict__
+
+
+class AttributeAttack(Attack):
+    """Class to wrap the attribute inference attack code."""
+
+    def __init__(self, args: AttributeAttackArgs = AttributeAttackArgs()):
+        self.attack_metrics: dict = {}
+        self.metadata: dict = {}
+        self.args = args
+
+    def __str__(self):
+        return "Attribute inference attack"
+
+    def attack(self, dataset: Data, target_model: BaseEstimator) -> None:
+        """Programmatic attack entry point
+
+        To be used when code has access to data class and trained target model
+
+        Parameters
+        ----------
+        dataset: attacks.dataset.Data
+            dataset as a Data class object
+        target_model: sklearn.base.BaseEstimator
+            target model that inherits from an sklearn BaseEstimator
+        """
+        self.attack_metrics = _attribute_inference(
+            target_model, dataset, self.args.n_cpu
+        )
+
+    def _construct_metadata(self) -> None:
+        """Constructs the metadata object. Called by the reporting method."""
+        self.metadata = {}
+        self.metadata["experiment_details"] = {}
+        self.metadata["experiment_details"].update(self.args.__dict__)
+        self.metadata["attack"] = str(self)
+
+    def make_report(self) -> dict:
+        """Create the report.
+
+        Creates the output report. If self.args.report_name is not None, it will also save the
+        information in json and pdf formats.
+
+        Returns
+        -------
+
+        output: dict
+            Dictionary containing all attack output.
+        """
+        output = {}
+        logger.info("Starting report, report_name = %s", self.args.report_name)
+        output["attack_metrics"] = self.attack_metrics
+        self._construct_metadata()
+        output["metadata"] = self.metadata
+        if self.args.report_name is not None:
+            json_report = json.dumps(output, cls=report.NumpyArrayEncoder)
+            with open(f"{self.args.report_name}.json", "w", encoding="utf-8") as f:
+                f.write(json_report)
+            logger.info("Wrote report to %s", f"{self.args.report_name}.json")
+
+            pdf = create_aia_report(output)
+            pdf.output(f"{self.args.report_name}.pdf", "F")
+            logger.info("Wrote pdf report to %s", f"{self.args.report_name}.pdf")
+        return output
+
+
+def _unique_max(confidences: list[float], threshold: float) -> bool:
+    """Returns whether there is a unique maximum confidence value above
+    threshold."""
+    if len(confidences) > 0:
+        max_conf = np.max(confidences)
+        if max_conf < threshold:
+            return False
+        unique, count = np.unique(confidences, return_counts=True)
+        for (u, c) in zip(unique, count):
+            if c == 1 and u == max_conf:
+                return True
+    return False
+
+
+def _get_inference_data(  # pylint: disable=too-many-locals
+    target_model: BaseEstimator, dataset: Data, feature_id: int, memberset: bool
+) -> tuple[np.ndarray, np.ndarray, float]:
+    """Returns a dataset of each sample with the attributes to test."""
+    attack_feature: dict = dataset.features[feature_id]
+    indices: list[int] = attack_feature["indices"]
+    unique = np.unique(dataset.x_orig[:, feature_id])
+    n_unique: int = len(unique)
+    if attack_feature["encoding"] == "onehot":
+        onehot_enc = OneHotEncoder()
+        values = onehot_enc.fit_transform(unique.reshape(-1, 1)).toarray()
+    else:
+        values = unique
+    # samples after encoding (e.g. one-hot)
+    samples: np.ndarray = dataset.x_train
+    # samples before encoding (e.g. str)
+    samples_orig: np.ndarray = dataset.x_train_orig
+    if not memberset:
+        samples = dataset.x_test
+        samples_orig = dataset.x_test_orig
+    n_samples, x_dim = np.shape(samples)
+    x_values = np.zeros((n_samples, n_unique, x_dim), dtype=np.float64)
+    y_values = target_model.predict(samples)
+    # for each sample to perform inference on
+    # add each possible missing feature value
+    for i, x in enumerate(samples):
+        for j, value in enumerate(values):
+            x_values[i][j] = np.copy(x)
+            x_values[i][j][indices] = value
+    _, counts = np.unique(samples_orig[:, feature_id], return_counts=True)
+    baseline = (np.max(counts) / n_samples) * 100
+    logger.debug("feature: %d x_values shape = %s", feature_id, np.shape(x_values))
+    logger.debug("feature: %d y_values shape = %s", feature_id, np.shape(y_values))
+    return x_values, y_values, baseline
+
+
+def _infer(  # pylint: disable=too-many-locals
+    target_model: BaseEstimator,
+    dataset: Data,
+    feature_id: int,
+    threshold: float,
+    memberset: bool,
+) -> tuple[int, int, float, int, int]:
+    """
+    For each possible missing value, compute the confidence scores and
+    label with the target model; if the label matches the known target model
+    label for the original sample, and the highest confidence score is unique,
+    infer that attribute if the confidence score is greater than a threshold.
+    """
+    logger.debug("Commencing attack on feature %d set %d", feature_id, int(memberset))
+    correct: int = 0  # number of correct inferences made
+    total: int = 0  # total number of inferences made
+    x_values, y_values, baseline = _get_inference_data(
+        target_model, dataset, feature_id, memberset
+    )
+    n_unique: int = len(x_values[1])
+    samples = dataset.x_train if memberset else dataset.x_test
+    for i, x in enumerate(x_values):  # each sample to perform inference on
+        # get model confidence scores for all possible values for the sample
+        confidence = target_model.predict_proba(x)
+        conf = []  # confidences for each possible value with correct label
+        attr = []  # features for each possible value with correct label
+        # for each possible attribute value,
+        # if the label matches the known target model label
+        # then store the confidence score and the tested feature vector
+        for j in range(n_unique):
+            this_label = np.argmax(confidence[j])
+            scores = confidence[j][this_label]
+            if this_label == y_values[i]:
+                conf.append(scores)
+                attr.append(x[j])
+        # is there is a unique maximum confidence score above threshold?
+        if _unique_max(conf, threshold):
+            total += 1
+            if (attr[np.argmax(conf)] == samples[i]).all():
+                correct += 1
+    logger.debug("Finished attacking feature %d", feature_id)
+    return correct, total, baseline, n_unique, len(samples)
+
+
+def report_categorical(results: dict) -> str:
+    """Returns a string report of the categorical results."""
+    results = results["categorical"]
+    msg = ""
+    for feature in results:
+        name = feature["name"]
+        _, _, _, n_unique, _ = feature["train"]
+        msg += f"Attacking categorical feature {name} with {n_unique} unique values:\n"
+        for tranche in ("train", "test"):
+            correct, total, baseline, _, n_samples = feature[tranche]
+            if total > 0:
+                msg += (
+                    f"Correctly inferred {(correct / total) * 100:.2f}% "
+                    f"of {(total / n_samples) * 100:.2f}% of the {tranche} set; "
+                    f"baseline: {baseline:.2f}%\n"
+                )
+            else:
+                msg += f"Unable to make any inferences of the {tranche} set\n"
+    return msg
+
+
+def report_quantitative(results: dict) -> str:
+    """Returns a string report of the quantitative results."""
+    results = results["quantitative"]
+    msg = ""
+    for feature in results:
+        msg += (
+            f"{feature['name']}: "
+            f"{feature['train']:.2f} train risk, "
+            f"{feature['test']:.2f} test risk\n"
+        )
+    return msg
+
+
+def plot_quantitative_risk(res: dict, savefile: str = "") -> None:
+    """Generates bar chart showing quantitative value risk scores."""
+    logger.debug("Plotting quantitative feature risk scores")
+    results = res["quantitative"]
+    if len(results) < 1:
+        return
+    x = np.arange(len(results))
+    ya = []
+    yb = []
+    names = []
+    for feature in results:
+        names.append(feature["name"])
+        ya.append(feature["train"] * 100)
+        yb.append(feature["test"] * 100)
+    fig, ax = plt.subplots(1, 1, figsize=(8, 5))
+    ax.set_xticks(x)
+    ax.set_xticklabels(names, rotation=90)
+    ax.set_ylim([0, 100])
+    ax.bar(x + 0.2, ya, 0.4, align="center", color=COLOR_A, label="train set")
+    ax.bar(x - 0.2, yb, 0.4, align="center", color=COLOR_B, label="test set")
+    title = "Percentage of Set at Risk for Quantitative Attributes"
+    ax.set_title(f"{res['name']}\n{title}")
+    ax.tick_params(axis="x", labelsize=10)
+    ax.tick_params(axis="y", labelsize=10)
+    ax.grid(linestyle="dotted", linewidth=1)
+    ax.legend(loc="best")
+    plt.margins(y=0)
+    plt.tight_layout()
+    if savefile != "":
+        fig.savefig(savefile + "_quant_risk.png", pad_inches=0, bbox_inches="tight")
+        logger.debug("Saved quantitative risk plot: %s", savefile)
+    else:
+        plt.show()
+
+
+def plot_categorical_risk(  # pylint: disable=too-many-locals
+    res: dict, savefile: str = ""
+) -> None:
+    """Generates bar chart showing categorical risk scores."""
+    logger.debug("Plotting categorical feature risk scores")
+    results: list[dict] = res["categorical"]
+    if len(results) < 1:
+        return
+    x: np.ndarray = np.arange(len(results))
+    ya: list[float] = []
+    yb: list[float] = []
+    names: list[str] = []
+    for feature in results:
+        names.append(feature["name"])
+        correct_a, total_a, baseline_a, _, _ = feature["train"]
+        correct_b, total_b, baseline_b, _, _ = feature["test"]
+        a = ((correct_a / total_a) * 100) - baseline_a if total_a > 0 else 0
+        b = ((correct_b / total_b) * 100) - baseline_b if total_b > 0 else 0
+        ya.append(a)
+        yb.append(b)
+    fig, ax = plt.subplots(1, 1, figsize=(8, 5))
+    ax.set_xticks(x)
+    ax.set_xticklabels(names, rotation=90)
+    ax.set_ylim([-100, 100])
+    ax.bar(x + 0.2, ya, 0.4, align="center", color=COLOR_A, label="train set")
+    ax.bar(x - 0.2, yb, 0.4, align="center", color=COLOR_B, label="test set")
+    title: str = "Improvement Over Most Common Value Estimate"
+    ax.set_title(f"{res['name']}\n{title}")
+    ax.tick_params(axis="x", labelsize=10)
+    ax.tick_params(axis="y", labelsize=10)
+    ax.grid(linestyle="dotted", linewidth=1)
+    ax.legend(loc="best")
+    plt.margins(y=0)
+    plt.tight_layout()
+    if savefile != "":
+        fig.savefig(savefile + "_cat_risk.png", pad_inches=0, bbox_inches="tight")
+        logger.debug("Saved categorical risk plot: %s", savefile)
+    else:
+        plt.show()
+
+
+def plot_categorical_fraction(  # pylint: disable=too-many-locals
+    res: dict, savefile: str = ""
+) -> None:
+    """Generates bar chart showing fraction of dataset inferred."""
+    logger.debug("Plotting categorical feature tranche sizes")
+    results: list[dict] = res["categorical"]
+    if len(results) < 1:
+        return
+    x: np.ndarray = np.arange(len(results))
+    ya: list[float] = []
+    yb: list[float] = []
+    names: list[str] = []
+    for feature in results:
+        names.append(feature["name"])
+        _, total_a, _, _, n_samples_a = feature["train"]
+        _, total_b, _, _, n_samples_b = feature["test"]
+        a = ((total_a / n_samples_a) * 100) if n_samples_a > 0 else 0
+        b = ((total_b / n_samples_b) * 100) if n_samples_b > 0 else 0
+        ya.append(a)
+        yb.append(b)
+    fig, ax = plt.subplots(1, 1, figsize=(8, 5))
+    ax.set_xticks(x)
+    ax.set_xticklabels(names, rotation=90)
+    ax.set_ylim([0, 100])
+    ax.bar(x + 0.2, ya, 0.4, align="center", color=COLOR_A, label="train set")
+    ax.bar(x - 0.2, yb, 0.4, align="center", color=COLOR_B, label="test set")
+    title: str = "Percentage of Set at Risk"
+    ax.set_title(f"{res['name']}\n{title}")
+    ax.tick_params(axis="x", labelsize=10)
+    ax.tick_params(axis="y", labelsize=10)
+    ax.grid(linestyle="dotted", linewidth=1)
+    ax.legend(loc="best")
+    plt.margins(y=0)
+    plt.tight_layout()
+    if savefile != "":
+        fig.savefig(savefile + "_cat_frac.png", pad_inches=0, bbox_inches="tight")
+        logger.debug("Saved categorical fraction plot: %s", savefile)
+    else:
+        plt.show()
+
+
+def plot_from_file(filename: str, savefile: str = "") -> None:
+    """Loads a results save file and plots risk scores."""
+    logger.debug("Loading from results file: %s", filename)
+    with open(filename + ".pickle", "rb") as handle:
+        results = pickle.load(handle)
+    plot_categorical_risk(results, savefile=savefile)
+    plot_categorical_fraction(results, savefile=savefile)
+    plot_quantitative_risk(results, savefile=savefile)
+
+
+def _infer_categorical(
+    target_model: BaseEstimator, dataset: Data, feature_id: int, threshold: float
+) -> dict:
+    """Returns the training and test set risks of a categorical feature."""
+    result: dict = {
+        "name": dataset.features[feature_id]["name"],
+        "train": _infer(target_model, dataset, feature_id, threshold, True),
+        "test": _infer(target_model, dataset, feature_id, threshold, False),
+    }
+    return result
+
+
+def _is_categorical(dataset: Data, feature_id: int) -> bool:
+    """Returns whether a feature is categorical.
+    For simplicity, assumes integer datatypes are categorical."""
+    encoding: str = dataset.features[feature_id]["encoding"]
+    if encoding[:3] in ("str", "int") or encoding[:6] in ("onehot"):
+        return True
+    return False
+
+
+def _attack_brute_force(
+    target_model: BaseEstimator,
+    dataset: Data,
+    features: list[int],
+    n_cpu: int,
+    attack_threshold: float = 0,
+) -> list[dict]:
+    """
+    Performs a brute force attribute inference attack by computing the target
+    model confidence scores for every value in the list and making an inference
+    if there is a unique highest confidence score that exceeds attack_threshold.
+    """
+    logger.debug("Brute force attacking categorical features")
+    args = [
+        (target_model, dataset, feature_id, attack_threshold) for feature_id in features
+    ]
+    with mp.Pool(processes=n_cpu) as pool:
+        results = pool.starmap(_infer_categorical, args)
+    return results
+
+
+def _get_bounds_risk_for_sample(  # pylint: disable=too-many-locals,too-many-arguments
+    target_model: BaseEstimator,
+    feat_id: int,
+    feat_min: float,
+    feat_max: float,
+    sample: np.ndarray,
+    c_min: float = 0,
+    protection_limit: float = 0.1,
+    feat_n: int = 100,
+) -> bool:
+    """Returns a bool based on conditions surrounding upper and lower bounds of
+    guesses that would lead to the same model confidence.
+
+    Parameters
+    ----------
+
+    target_model: BaseEstimator
+        Trained target model.
+    feat_id: int
+        Index of missing feature.
+    feat_min: float
+        Minimum value of missing feature.
+    feat_max: float
+        Maximum value of missing feature.
+    sample: np.ndarray
+        Original known feature vector.
+    c_min: float
+        Defines the confidence threshold below which we say we don't care.
+    protection_limit: float
+        Lower [upper] bound on estimated value must not be
+        above[below] lower[upper] bounds e.g. 10% of value.
+    feat_n: int
+        Number of attribute values to test per sample.
+
+    Returns
+    -------
+    A bool representing whether the quantitative feature is at risk for the sample.
+
+    """
+    # attribute values to test - linearly sampled
+    x_feat = np.linspace(feat_min, feat_max, feat_n, endpoint=True)
+    # get known label
+    label: int = int(target_model.predict(sample.reshape(1, -1))[0])
+    # a matrix containing feature vector with linearly spaced target attribute
+    x1 = np.repeat(sample.reshape(1, -1), feat_n, axis=0)
+    x1[:, feat_id] = x_feat
+    # get the target model confidences across the attribute range
+    confidences = target_model.predict_proba(x1)
+    scores = confidences[:, label]  # scores just for the model predicted label
+    peak: float = np.max(scores)
+    # find lowest and highest values with peak confidence
+    lower_bound_index: int = 0
+    while scores[lower_bound_index] < peak:
+        lower_bound_index += 1
+    upper_bound_index: int = feat_n - 1
+    while scores[upper_bound_index] < peak:
+        upper_bound_index -= 1
+    # condition 1: confidence in prediction above some threshold
+    # condition 2: confidence for true value == max_confidence
+    # condition 3: lower bound above lower protection limit
+    # condition 4: upper boiund of estiamte below upper protection limit
+    actual_value = sample[feat_id]
+    actual_probs = target_model.predict_proba(sample.reshape(1, -1))[0]
+    lower_bound: float = x_feat[lower_bound_index]
+    upper_bound: float = x_feat[upper_bound_index]
+    if (
+        peak > c_min
+        and actual_probs[label] == peak
+        and lower_bound >= (1 - protection_limit) * actual_value
+        and upper_bound <= (1 + protection_limit) * actual_value
+    ):
+        return True
+    return False
+
+
+def _get_bounds_risk_for_feature(
+    target_model: BaseEstimator, feature_id: int, samples: np.ndarray
+) -> float:
+    """Returns the average feature risk score over a set of samples."""
+    feature_risk: int = 0
+    n_samples: int = len(samples)
+    feat_min: float = np.min(samples[:, feature_id])
+    feat_max: float = np.max(samples[:, feature_id])
+    for i in range(n_samples):
+        sample = samples[i]
+        risk = _get_bounds_risk_for_sample(
+            target_model, feature_id, feat_min, feat_max, sample
+        )
+        if risk:
+            feature_risk += 1
+    if n_samples < 1:
+        return 0
+    return feature_risk / n_samples
+
+
+def _get_bounds_risk(
+    target_model: BaseEstimator,
+    feature_name: str,
+    feature_id: int,
+    x_train: np.ndarray,
+    x_test: np.ndarray,
+) -> dict:
+    """Returns a dictionary containing the training and test set risks of a
+    quantitative feature."""
+    risk: dict = {
+        "name": feature_name,
+        "train": _get_bounds_risk_for_feature(target_model, feature_id, x_train),
+        "test": _get_bounds_risk_for_feature(target_model, feature_id, x_test),
+    }
+    return risk
+
+
+def _get_bounds_risks(
+    target_model: BaseEstimator, dataset: Data, features: list[int], n_cpu: int
+) -> list[dict]:
+    """Computes the bounds risk for all specified features."""
+    logger.debug("Computing bounds risk for all specified features")
+    args = [
+        (
+            target_model,
+            dataset.features[feature_id]["name"],
+            feature_id,
+            dataset.x_train,
+            dataset.x_test,
+        )
+        for feature_id in features
+    ]
+    with mp.Pool(processes=n_cpu) as pool:
+        results = pool.starmap(_get_bounds_risk, args)
+    return results
+
+
+def _attribute_inference(
+    target_model: BaseEstimator,
+    dataset: Data,
+    n_cpu: int,
+) -> dict:
+    """
+    Execute attribute inference attacks on a dataset given a trained model.
+    """
+    # brute force attack categorical attributes using dataset unique values
+    logger.debug("Attacking dataset: %s", dataset.name)
+    logger.debug("Attacking categorical attributes...")
+    feature_list: list[int] = []
+    for feature in range(dataset.n_features):
+        if _is_categorical(dataset, feature):
+            feature_list.append(feature)
+    results_a: list[dict] = _attack_brute_force(
+        target_model, dataset, feature_list, n_cpu
+    )
+    # compute risk scores for quantitative attributes
+    logger.debug("Attacking quantitative attributes...")
+    feature_list = []
+    for feature in range(dataset.n_features):
+        if not _is_categorical(dataset, feature):
+            feature_list.append(feature)
+    results_b: list[dict] = _get_bounds_risks(
+        target_model, dataset, feature_list, n_cpu
+    )
+    # combine results into single object
+    results: dict = {
+        "name": dataset.name,
+        "categorical": results_a,
+        "quantitative": results_b,
+    }
+    return results
+
+
+def create_aia_report(output: dict, name: str = "aia_report") -> FPDF:
+    """Creates PDF report."""
+    aia_metrics = output["attack_metrics"]
+    metadata = output["metadata"]
+    plot_categorical_risk(aia_metrics, name)
+    plot_categorical_fraction(aia_metrics, name)
+    plot_quantitative_risk(aia_metrics, name)
+    pdf = FPDF()
+    pdf.add_page()
+    pdf.set_xy(0, 0)
+    report.title(pdf, "Attribute Inference Attack Report")
+    report.subtitle(pdf, "Introduction")
+    report.subtitle(pdf, "Metadata")
+    for key, value in metadata["experiment_details"].items():
+        report.line(pdf, f"{key:>30s}: {str(value):30s}", font="courier")
+    report.subtitle(pdf, "Metrics")
+    categ_rep = report_categorical(aia_metrics).split("\n")
+    quant_rep = report_quantitative(aia_metrics).split("\n")
+    report.line(pdf, "Categorical Features:", font="courier")
+    for line in categ_rep:
+        report.line(pdf, line, font="courier")
+    report.line(pdf, "Quantitative Features:", font="courier")
+    for line in quant_rep:
+        report.line(pdf, line, font="courier")
+    pdf.add_page()
+    report.subtitle(pdf, "Plots")
+    if len(aia_metrics["categorical"]) > 0:
+        pdf.image(name + "_cat_risk.png", x=None, y=None, w=150, h=0, type="", link="")
+        pdf.image(name + "_cat_frac.png", x=None, y=None, w=150, h=0, type="", link="")
+    if len(aia_metrics["quantitative"]) > 0:
+        pdf.image(
+            name + "_quant_risk.png", x=None, y=None, w=150, h=0, type="", link=""
+        )
+    return pdf
diff --git a/attacks/dataset.py b/attacks/dataset.py
new file mode 100644
index 00000000..9804f82a
--- /dev/null
+++ b/attacks/dataset.py
@@ -0,0 +1,70 @@
+"""dataset.py - class to represent a dataset"""
+
+import numpy as np
+
+
+class Data:  # pylint: disable=too-many-instance-attributes
+    """Stripped down Data class"""
+
+    def __init__(self) -> None:
+        self.name: str = ""
+        self.n_samples: int = 0
+        self.x_train: np.ndarray
+        self.y_train: np.ndarray
+        self.x_test: np.ndarray
+        self.y_test: np.ndarray
+
+        self.features: dict = {}
+        self.n_features: int = 0
+        self.x_orig: np.ndarray
+        self.y_orig: np.ndarray
+        self.x_train_orig: np.ndarray
+        self.y_train_orig: np.ndarray
+        self.x_test_orig: np.ndarray
+        self.y_test_orig: np.ndarray
+        self.n_samples_orig: int = 0
+
+    def add_processed_data(
+        self,
+        x_train: np.ndarray,
+        y_train: np.ndarray,
+        x_test: np.ndarray,
+        y_test: np.ndarray,
+    ) -> None:
+        """Add a processed and split dataset"""
+        self.x_train = x_train
+        self.y_train = np.array(y_train, int)
+        self.x_test = x_test
+        self.y_test = np.array(y_test, int)
+        self.n_samples = len(x_train) + len(y_train)
+
+    def add_feature(self, name: str, indices: list[int], encoding: str) -> None:
+        """Add a feature description to the data dictionary."""
+        index: int = len(self.features)
+        self.features[index] = {
+            "name": name,
+            "indices": indices,
+            "encoding": encoding,
+        }
+        self.n_features = len(self.features)
+
+    def add_raw_data(  # pylint: disable=too-many-arguments
+        self,
+        x_orig: np.ndarray,
+        y_orig: np.ndarray,
+        x_train_orig: np.ndarray,
+        y_train_orig: np.ndarray,
+        x_test_orig: np.ndarray,
+        y_test_orig: np.ndarray,
+    ) -> None:
+        """Add original unprocessed dataset"""
+        self.x_orig = x_orig
+        self.y_orig = y_orig
+        self.x_train_orig = x_train_orig
+        self.y_train_orig = y_train_orig
+        self.x_test_orig = x_test_orig
+        self.y_test_orig = y_test_orig
+        self.n_samples_orig = len(x_orig)
+
+    def __str__(self):
+        return self.name
diff --git a/attacks/likelihood_attack.py b/attacks/likelihood_attack.py
index 9f3c2416..a7e4118a 100644
--- a/attacks/likelihood_attack.py
+++ b/attacks/likelihood_attack.py
@@ -6,19 +6,24 @@
 import json
 import logging
 import importlib
-from typing import Iterable, Any, Dict
+from typing import Any
+
+from collections.abc import Iterable, Hashable
 import numpy as np
 from scipy.stats import norm
+import sklearn
 from sklearn.ensemble import RandomForestClassifier
 from sklearn.model_selection import train_test_split
 from  sklearn.datasets import load_breast_cancer
 
 from attacks import report
 from attacks import metrics
+from attacks.attack import Attack
+from attacks.dataset import Data
 
 logging.basicConfig(level=logging.INFO)
 
-N_SHADOW_MODELS = 256 # Number of shadow models that should be trained
+N_SHADOW_MODELS = 100 # Number of shadow models that should be trained
 EPS = 1e-16 # Used to avoid numerical issues in logit function
 P_THRESH = 0.05 # default significance threshold
 
@@ -57,304 +62,463 @@ def _logit(p: float) -> float:
     return li
 
 
-def likelihood_scenario( # pylint: disable = too-many-locals, too-many-arguments
-    shadow_clf,
-    X_target_train: Iterable[float],
-    y_target_train: Iterable[float],
-    target_train_preds: Iterable[float],
-    X_shadow_train: Iterable[float],
-    y_shadow_train: Iterable[float],
-    shadow_train_preds: Iterable[float],
-    n_shadow_models: int=N_SHADOW_MODELS
-):
-    """Implements the likelihood test, using the "offline" version
-    See p.6 (top of second column) for details
-
-    Parameters
-    ----------
-    shadow_clf: sklearn.Model
-        An sklearn classifier that will be trained to form the shadow model. All hyper-parameters
-        should have been set.
-    X_target_train: np.ndarray
-        Data that was used to train the target model
-    y_target_train: np.ndarray
-        Labels that were used to train the target model
-    target_train_preds: np.ndarray
-        Array of predictions produced by the target model on the training data
-    X_shadow_train: np.ndarray
-        Data that will be used to train the shadow models
-    y_shadow_train: np.ndarray
-        Labels that will be used to train the shadow model
-    shadow_train_preds: np.ndarray
-        Array of predictions produced by the target model on the shadow data
-    n_shadow_models: int
-        The number of shadow models to build
+class LIRAAttackArgs:
+    """LIRA Attack arguments"""
+    def __init__(self, **kwargs):
+        self.__dict__['n_shadow_models'] = N_SHADOW_MODELS
+        self.__dict__['p_thresh'] = 0.05
+        self.__dict__['report_name'] = None
+        self.__dict__['json_file'] = "config.json"
+        self.__dict__.update(kwargs)
+
+    def __str__(self):
+        return ",".join([f"{str(key)}: {str(value)}" for key, value in self.__dict__.items()])
+
+    def set_param(self, key: Hashable, value: Any) -> None:
+        """Set a parameter"""
+        self.__dict__[key] = value
+
+    def get_args(self) -> dict:
+        """Return arguments"""
+        return self.__dict__
+
+class LIRAAttack(Attack):
+    """The main LIRA Attack class"""
+    def __init__(self, args: LIRAAttackArgs=LIRAAttackArgs()) -> None:
+        self.attack_metrics = None
+        self.dummy_attack_metrics = None
+        self.metadata = None
+        self.args = args
+
+    def __str__(self):
+        return "LIRA Attack"
+
+    def attack(
+        self,
+        dataset: Data,
+        target_model: sklearn.base.BaseEstimator) -> None:
+        """Programmatic attack running
+        Runs a LIRA attack from a dataset object and a target model
+
+        Parameters
+        ----------
+        dataset: attacks.dataset.Data
+            Dataset as an instance of the Data class. Needs to have x_train, x_test, y_train
+            and y_test set.
+        target_mode: sklearn.base.BaseEstimator
+            Trained target model. Any class that implements the sklearn.base.BaseEstimator
+            interface (i.e. has fit, predict and predict_proba methods)
+        """
+
+        shadow_clf = sklearn.base.clone(target_model)
+
+        dataset = self._check_and_update_dataset(dataset, target_model)
+
+        self.run_scenario_from_preds(
+            shadow_clf,
+            dataset.x_train,
+            dataset.y_train,
+            target_model.predict_proba(dataset.x_train),
+            dataset.x_test,
+            dataset.y_test,
+            target_model.predict_proba(dataset.x_test)
+        )
 
-    Returns
-    -------
-    mia_scores: np.ndarray
-        Attack probabilities of belonging to the training set or not
-    mia_labels: np.ndarray
-        True labels of belonging to the training set or not
-    mia_cls: DummyClassifier
-        A DummyClassifier that directly returns the scores for compatibility with code
-        in metrics.py
-
-    Examples
-    --------
-
-    >>> X, y = load_breast_cancer(return_X_y=True, as_frame=False)
-    >>> train_X, test_X, train_y, test_y = train_test_split(
-    >>>   X, y, test_size=0.5, stratify=y
-    >>> )
-    >>> rf = RandomForestClassifier(min_samples_leaf=1, min_samples_split=2)
-    >>> rf.fit(train_X, train_y)
-    >>> mia_test_probs, mia_test_labels, mia_clf = likelihood_scenario(
-    >>>     RandomForestClassifier(min_samples_leaf=1, min_samples_split=2, max_depth=10),
-    >>>     train_X,
-    >>>     train_y,
-    >>>     rf.predict_proba(train_X),
-    >>>     test_X,
-    >>>     test_y,
-    >>>     rf.predict_proba(test_X),
-    >>>     n_shadow_models=100
-    >>> )
-    """
-    logger = logging.getLogger("lr-scenario")
-    n_train_rows, _ = X_target_train.shape
-    n_shadow_rows, _ = X_shadow_train.shape
-    indices = np.arange(0, n_train_rows + n_shadow_rows, 1)
-
-    # Combine taregt and shadow train, from which to sample datasets
-    combined_X_train = np.vstack((
-        X_target_train,
-        X_shadow_train
-    ))
-    combined_y_train = np.hstack((
-        y_target_train,
-        y_shadow_train
-    ))
-
-    train_row_to_confidence = {i: [] for i in range(n_train_rows)}
-    shadow_row_to_confidence = {i: [] for i in range(n_shadow_rows)}
-
-    # Train N_SHADOW_MODELS shadow models
-    logger.info("Training shadow models")
-    for model_idx in range(n_shadow_models):
-        if model_idx % 10 == 0:
-            logger.info("Trained %d models", model_idx)
-        # Pick the indices to use for training this one
-        np.random.seed(model_idx) # Reproducibility
-        these_idx = np.random.choice(indices, n_train_rows, replace=False)
-        temp_X_train = combined_X_train[these_idx, :]
-        temp_y_train = combined_y_train[these_idx]
-
-        # Fit the shadow model
-        shadow_clf.set_params(random_state=model_idx)
-        shadow_clf.fit(temp_X_train, temp_y_train)
-
-        # Get the predicted probabilities on the training data
-        confidences = shadow_clf.predict_proba(X_target_train)
-        these_idx = set(these_idx)
+    def _check_and_update_dataset(
+        self,
+        dataset: Data,
+        target_model: sklearn.base.BaseEstimator) -> tuple[np.ndarray, np.ndarray]:
+        """Makes sure that it is ok to use the class variables to index the prediction
+        arrays. This has two steps:
+        1. Replacing the values in y_train with their position in target_model.classes (will
+           normally result in no change)
+        2. Removing from the test set any rows corresponding to classes that are not in the
+           training set.
+        """
+
+        y_train_new = []
+        classes = list(target_model.classes_) # pylint: disable = protected-access
+        for y in dataset.y_train:
+            y_train_new.append(classes.index(y))
+
+        dataset.y_train = np.array(y_train_new, int)
+        print(f'new ytrain has values and counts: {np.unique(dataset.y_train,return_counts=True)}')
+        ok_pos = []
+        y_test_new = []
+        for i, y in enumerate(dataset.y_test):
+            if y in classes:
+                ok_pos.append(i)
+                y_test_new.append(classes.index(y))
+
+        if len(y_test_new) != len(dataset.x_test):
+            dataset.x_test = dataset.x_test[ok_pos, :]
+        dataset.y_test = np.array(y_test_new, int)
+        print(f'new ytest has values and counts: {np.unique(dataset.y_test,return_counts=True)}')
+
+
+        return dataset
+
+    def run_scenario_from_preds( # pylint: disable = too-many-statements
+        self,
+        shadow_clf: sklearn.base.BaseEstimator,
+        X_target_train: Iterable[float],
+        y_target_train: Iterable[float],
+        target_train_preds: Iterable[float],
+        X_shadow_train: Iterable[float],
+        y_shadow_train: Iterable[float],
+        shadow_train_preds: Iterable[float]) -> tuple[
+            np.ndarray,
+            np.ndarray,
+            sklearn.base.BaseEstimator
+        ]:
+
+        # def run_scenario_from_preds( # pylint: disable = too-many-locals, too-many-arguments
+        #     shadow_clf,
+        #     X_target_train: Iterable[float],
+        #     y_target_train: Iterable[float],
+        #     target_train_preds: Iterable[float],
+        #     X_shadow_train: Iterable[float],
+        #     y_shadow_train: Iterable[float],
+        #     shadow_train_preds: Iterable[float],
+
+        """Implements the likelihood test, using the "offline" version
+        See p.6 (top of second column) for details
+
+        Parameters
+        ----------
+        shadow_clf: sklearn.Model
+            An sklearn classifier that will be trained to form the shadow model.
+            All hyper-parameters should have been set.
+        X_target_train: np.ndarray
+            Data that was used to train the target model
+        y_target_train: np.ndarray
+            Labels that were used to train the target model
+        target_train_preds: np.ndarray
+            Array of predictions produced by the target model on the training data
+        X_shadow_train: np.ndarray
+            Data that will be used to train the shadow models
+        y_shadow_train: np.ndarray
+            Labels that will be used to train the shadow model
+        shadow_train_preds: np.ndarray
+            Array of predictions produced by the target model on the shadow data
+
+
+        Returns
+        -------
+        mia_scores: np.ndarray
+            Attack probabilities of belonging to the training set or not
+        mia_labels: np.ndarray
+            True labels of belonging to the training set or not
+        mia_cls: DummyClassifier
+            A DummyClassifier that directly returns the scores for compatibility with code
+            in metrics.py
+
+        Examples
+        --------
+
+        >>> X, y = load_breast_cancer(return_X_y=True, as_frame=False)
+        >>> train_X, test_X, train_y, test_y = train_test_split(
+        >>>   X, y, test_size=0.5, stratify=y
+        >>> )
+        >>> rf = RandomForestClassifier(min_samples_leaf=1, min_samples_split=2)
+        >>> rf.fit(train_X, train_y)
+        >>> mia_test_probs, mia_test_labels, mia_clf = likelihood_scenario(
+        >>>     RandomForestClassifier(min_samples_leaf=1, min_samples_split=2, max_depth=10),
+        >>>     train_X,
+        >>>     train_y,
+        >>>     rf.predict_proba(train_X),
+        >>>     test_X,
+        >>>     test_y,
+        >>>     rf.predict_proba(test_X),
+        >>>     n_shadow_models=100
+        >>> )
+        """
+
+        logger = logging.getLogger("lr-scenario")
+
+        n_train_rows, _ = X_target_train.shape
+        n_shadow_rows, _ = X_shadow_train.shape
+        indices = np.arange(0, n_train_rows + n_shadow_rows, 1)
+
+        # Combine taregt and shadow train, from which to sample datasets
+        combined_X_train = np.vstack((
+            X_target_train,
+            X_shadow_train
+        ))
+        combined_y_train = np.hstack((
+            y_target_train,
+            y_shadow_train
+        ))
+
+        train_row_to_confidence = {i: [] for i in range(n_train_rows)}
+        shadow_row_to_confidence = {i: [] for i in range(n_shadow_rows)}
+
+        # Train N_SHADOW_MODELS shadow models
+        logger.info("Training shadow models")
+        for model_idx in range(self.args.n_shadow_models):
+            if model_idx % 10 == 0:
+                logger.info("Trained %d models", model_idx)
+            # Pick the indices to use for training this one
+            np.random.seed(model_idx) # Reproducibility
+            these_idx = np.random.choice(indices, n_train_rows, replace=False)
+            temp_X_train = combined_X_train[these_idx, :]
+            temp_y_train = combined_y_train[these_idx]
+
+            # Fit the shadow model
+            shadow_clf.set_params(random_state=model_idx)
+            shadow_clf.fit(temp_X_train, temp_y_train)
+
+            # map a class to a column
+            class_map = {
+                c: i for i, c in enumerate(shadow_clf.classes_)
+            }
+
+
+            # Get the predicted probabilities on the training data
+            confidences = shadow_clf.predict_proba(X_target_train)
+            #print(f'shadow clf returned confidences with shape {confidences.shape}')
+
+            these_idx = set(these_idx)
+            for i in range(n_train_rows):
+                if i not in these_idx:
+                    # If i was _not_ used for training, incorporate the logit of its confidence of
+                    # being correct - TODO: should we just be taking max??
+                    cl_pos = class_map.get(y_target_train[i], -1)
+                    if cl_pos >= 0:
+                        train_row_to_confidence[i].append(
+                            _logit(
+                                confidences[i, y_target_train[cl_pos]]
+                            )
+                        )
+                    else:
+                        train_row_to_confidence[i].append(_logit(0))
+            # Same process for shadow data
+            shadow_confidences = shadow_clf.predict_proba(X_shadow_train)
+            for i in range(n_shadow_rows):
+                if i + n_train_rows not in these_idx:
+                    cl_pos = class_map.get(y_shadow_train[i], -1)
+                    if cl_pos >= 0:
+                        shadow_row_to_confidence[i].append(
+                            _logit(
+                                shadow_confidences[i, y_shadow_train[cl_pos]]
+                            )
+                        )
+                    else:
+                        shadow_row_to_confidence[i].append(_logit(0))
+
+        # Do the test described in the paper in each case
+        mia_scores = []
+        mia_labels = []
+        logger.info("Computing scores for train rows")
         for i in range(n_train_rows):
-            if i not in these_idx:
-                # If i was _not_ used for training, incorporate the logit of its confidence of
-                # being correct - TODO: should we just be taking max??
-                train_row_to_confidence[i].append(
-                    _logit(
-                        confidences[i, y_target_train[i]]
-                    )
-                )
-        # Same process for shadow data
-        shadow_confidences = shadow_clf.predict_proba(X_shadow_train)
+            true_score = _logit(target_train_preds[i, y_target_train[i]])
+            null_scores = np.array(train_row_to_confidence[i])
+            mean_null = null_scores.mean()
+            var_null = max(null_scores.var(), EPS) # var can be zero in some cases
+            prob = norm.cdf(true_score, loc=mean_null, scale=np.sqrt(var_null))
+            mia_scores.append([1 - prob, prob])
+            mia_labels.append(1)
+
+        logger.info("Computing scores for shadow rows")
         for i in range(n_shadow_rows):
-            if i + n_train_rows not in these_idx:
-                shadow_row_to_confidence[i].append(
-                    _logit(
-                        shadow_confidences[i, y_shadow_train[i]]
-                    )
-                )
-
-    # Do the test described in the paper in each case
-    mia_scores = []
-    mia_labels = []
-    logger.info("Computing scores for train rows")
-    for i in range(n_train_rows):
-        true_score = _logit(target_train_preds[i, y_target_train[i]])
-        null_scores = np.array(train_row_to_confidence[i])
-        mean_null = null_scores.mean()
-        var_null = max(null_scores.var(), EPS) # var can be zero in some cases
-        prob = norm.cdf(true_score, loc=mean_null, scale=np.sqrt(var_null))
-        mia_scores.append([1 - prob, prob])
-        mia_labels.append(1)
-
-    logger.info("Computing scores for shadow rows")
-    for i in range(n_shadow_rows):
-        true_score = _logit(shadow_train_preds[i, y_shadow_train[i]])
-        null_scores = np.array(shadow_row_to_confidence[i])
-        mean_null = null_scores.mean()
-        var_null = max(null_scores.var(), EPS) # var can be zeros in some cases
-        prob = norm.cdf(true_score, loc=mean_null, scale=np.sqrt(var_null))
-        mia_scores.append([1 - prob, prob])
-        mia_labels.append(0)
-
-    mia_clf = DummyClassifier()
-    logger.info("Finished scenario")
-    return np.array(mia_scores), np.array(mia_labels), mia_clf
-
-def _run_attack(args: dict) -> None: # pylint: disable = too-many-locals
-    """Runs an attack based on the args parsed from the command line
-    """
-    logger = logging.getLogger("run-attack")
-    logger.info("Reading config from %s", args.json_file)
-    with open(args.json_file, 'r', encoding='utf-8') as f:
-        config = json.loads(f.read())
-
-    logger.info("Loading training data csv from %s", config['training_data_file'])
-    training_data = np.loadtxt(config['training_data_file'], delimiter=",")
-    train_X = training_data[:, :-1]
-    train_y = training_data[:, -1].flatten().astype(int)
-    logger.info("Loaded %d rows", len(train_X))
-
-    logger.info("Loading test data csv from %s", config['testing_data_file'])
-    test_data = np.loadtxt(config['testing_data_file'], delimiter=",")
-    test_X = test_data[:, :-1]
-    test_y = test_data[:, -1].flatten().astype(int)
-    logger.info("Loaded %d rows", len(test_X))
-
-    logger.info("Loading train predictions form %s", config['training_preds_file'])
-    train_preds = np.loadtxt(config['training_preds_file'], delimiter=",")
-    assert len(train_preds) == len(train_X)
-
-    logger.info("Loading test predictions form %s", config['testing_preds_file'])
-    test_preds = np.loadtxt(config['testing_preds_file'], delimiter=",")
-    assert len(test_preds) == len(test_X)
-
-    clf_module_name, clf_class_name = config['target_model']
-    module = importlib.import_module(clf_module_name)
-    clf_class = getattr(module, clf_class_name)
-    clf_params = config['target_hyppars']
-    clf = clf_class(**clf_params)
-    logger.info("Created model: %s", str(clf))
-    mia_test_probs, mia_test_labels, mia_clf = likelihood_scenario(
-        RandomForestClassifier(min_samples_leaf=1, min_samples_split=2, max_depth=10),
-        train_X,
-        train_y,
-        train_preds,
-        test_X,
-        test_y,
-        test_preds,
-        n_shadow_models=args.n_shadow_models
-    )
-    metadata = {
-        'input-config': args.json_file,
-        'n_shadow_models': args.n_shadow_models
-    }
-    logger.info("Computing metrics")
-    mia_metrics = metrics.get_metrics(
-        mia_clf,
-        mia_test_probs,
-        mia_test_labels
-    )
-    # Check for significance of AUC and PDIF
-    logger.info("Creating report")
-    pdif = np.exp(-mia_metrics['PDIF01'])
-    metadata['PDIF_sig'] = f"Significant at p={args.p_thresh}" if pdif <= args.p_thresh \
-        else f"Not significant at p={args.p_thresh}"
-    auc_p, auc_std = metrics.auc_p_val(
-        mia_metrics['AUC'],
-        mia_test_labels.sum(),
-        len(mia_test_labels) - mia_test_labels.sum()
-    )
-    metadata['AUC_p'] = auc_p
-    metadata['AUC_sig'] = f"Significant at p={args.p_thresh}" if auc_p <= args.p_thresh \
-        else f"Not significant at p={args.p_thresh}"
-    metadata['AUC NULL 3sd range'] = f"{0.5 - 3 * auc_std} -> {0.5 + 3 * auc_std}"
-    pdf = report.create_lr_report(metadata, mia_metrics)
-    pdf.output(args.pdf_name, 'F')
+            true_score = _logit(shadow_train_preds[i, y_shadow_train[i]])
+            null_scores = np.array(shadow_row_to_confidence[i])
+            mean_null = null_scores.mean()
+            var_null = max(null_scores.var(), EPS) # var can be zeros in some cases
+            prob = norm.cdf(true_score, loc=mean_null, scale=np.sqrt(var_null))
+            mia_scores.append([1 - prob, prob])
+            mia_labels.append(0)
+
+        mia_clf = DummyClassifier()
+        logger.info("Finished scenario")
+        self.attack_metrics = [
+            metrics.get_metrics(mia_clf, np.array(mia_scores), np.array(mia_labels))
+        ]
+
+    def example(self) -> None: # pylint: disable = too-many-locals
+        """Runs an example attack using data from sklearn
+
+        Generates example data, trains a classifier and tuns the attack
+        """
+        X, y = load_breast_cancer(return_X_y=True, as_frame=False)
+        train_X, test_X, train_y, test_y = train_test_split(
+            X, y, test_size=0.5, stratify=y
+        )
+        rf = RandomForestClassifier(min_samples_leaf=1, min_samples_split=2)
+        rf.fit(train_X, train_y)
+        self.run_scenario_from_preds(
+            sklearn.base.clone(rf),
+            train_X,
+            train_y,
+            rf.predict_proba(train_X),
+            test_X,
+            test_y,
+            rf.predict_proba(test_X)
+        )
 
+    def _construct_metadata(self) -> None:
+        """Constructs the metadata object. Called by the reporting method"""
+        self.metadata = {}
+        self.metadata['experiment_details'] = {}
+        self.metadata['experiment_details'].update(self.args.__dict__)
+        if 'func' in self.metadata['experiment_details']:
+            del self.metadata['experiment_details']['func']
 
-def _example(args: Dict) -> None: # pylint: disable = too-many-locals
-    """Runs an example attack using data from sklearn
-    """
-    X, y = load_breast_cancer(return_X_y=True, as_frame=False)
-    train_X, test_X, train_y, test_y = train_test_split(
-        X, y, test_size=0.5, stratify=y
-    )
-    rf = RandomForestClassifier(min_samples_leaf=1, min_samples_split=2)
-    rf.fit(train_X, train_y)
-    mia_test_probs, mia_test_labels, mia_clf = likelihood_scenario(
-        RandomForestClassifier(min_samples_leaf=1, min_samples_split=2, max_depth=10),
-        train_X,
-        train_y,
-        rf.predict_proba(train_X),
-        test_X,
-        test_y,
-        rf.predict_proba(test_X),
-        n_shadow_models=args.n_shadow_models
-    )
-    metadata = {
-        'input-data': "Breast Cancer",
-        'n_shadow_models': args.n_shadow_models
-    }
-    mia_metrics = metrics.get_metrics(
-        mia_clf,
-        mia_test_probs,
-        mia_test_labels
-    )
-    # Check for significance of AUC and PDIF
-    pdif = np.exp(-mia_metrics['PDIF01'])
-    metadata['PDIF_sig'] = f"Significant at p={args.p_thresh}" if pdif <= args.p_thresh \
-        else f"Not significant at p={args.p_thresh}"
-
-    auc_p, auc_std = metrics.auc_p_val(
-        mia_metrics['AUC'],
-        mia_test_labels.sum(),
-        (1 - mia_test_labels).sum()
-    )
-    metadata['AUC_sig'] = f"Significant at p={args.p_thresh}" if auc_p <= args.p_thresh \
-        else f"Not significant at p={args.p_thresh}"
-    metadata['AUC NULL 3sd range'] = f"{0.5 - 3 * auc_std} -> {0.5 + 3 * auc_std}"
-    pdf = report.create_lr_report(metadata, mia_metrics)
-    pdf.output(args.pdf_name, 'F')
+        self.metadata['global_metrics'] = {}
 
+        pdif = np.exp(-self.attack_metrics[0]['PDIF01'])
 
-def _setup_example_data(_: Any):
-    """Method to create example data and save (including config). Intended to allow users
-    to see how they would need to setup their own data.
-    """
-    X, y = load_breast_cancer(return_X_y=True)
-    train_X, test_X, train_y, test_y = train_test_split(
-        X, y, test_size=0.5, stratify=y
-    )
-    rf = RandomForestClassifier(min_samples_split=2, min_samples_leaf=1)
-    rf.fit(train_X, train_y)
-    train_data = np.hstack((train_X, train_y[:, None]))
-    np.savetxt("train_data.csv", train_data, delimiter=",")
-
-    test_data = np.hstack((test_X, test_y[:, None]))
-    np.savetxt("test_data.csv", test_data, delimiter=",")
-
-    train_preds = rf.predict_proba(train_X)
-    test_preds = rf.predict_proba(test_X)
-    np.savetxt("train_preds.csv", train_preds, delimiter=",")
-    np.savetxt("test_preds.csv", test_preds, delimiter=",")
-
-
-    config = {
-        "training_data_file": "train_data.csv",
-        "testing_data_file": "test_data.csv",
-        "training_preds_file": "train_preds.csv",
-        "testing_preds_file": "test_preds.csv",
-        "target_model": ["sklearn.ensemble", "RandomForestClassifier"],
-        "target_hyppars": {
-            "min_samples_split": 2,
-            "min_samples_leaf": 1
+        self.metadata['global_metrics']['PDIF_sig'] = f"Significant at p={self.args.p_thresh}" if \
+            pdif <= self.args.p_thresh else f"Not significant at p={self.args.p_thresh}"
+
+        auc_p, auc_std = metrics.auc_p_val(
+            self.attack_metrics[0]['AUC'],
+            self.attack_metrics[0]['n_pos_test_examples'],
+            self.attack_metrics[0]['n_neg_test_examples']
+        )
+        self.metadata['global_metrics']['AUC_sig'] = f"Significant at p={self.args.p_thresh}" if \
+            auc_p <= self.args.p_thresh else f"Not significant at p={self.args.p_thresh}"
+        self.metadata['global_metrics']['AUC NULL 3sd range'] = \
+            f"{0.5 - 3 * auc_std} -> {0.5 + 3 * auc_std}"
+
+        self.metadata['attack'] = str(self)
+
+    def make_report(self) -> dict:
+        """Create the report
+
+        Creates the output report. If self.args.report_name is not None, it will also save the
+        information in json and pdf formats
+
+        Returns
+        -------
+
+        output: Dict
+            Dictionary containing all attack output
+        """
+        logger = logging.getLogger("reporting")
+        output = {}
+        logger.info("Starting report, report_name = %s", self.args.report_name)
+        output['attack_metrics'] = self.attack_metrics
+        self._construct_metadata()
+        output['metadata'] = self.metadata
+        if self.args.report_name is not None:
+            json_report = report.create_json_report(output)
+            with open(f"{self.args.report_name}.json", 'w', encoding='utf-8') as f:
+                f.write(json_report)
+            logger.info("Wrote report to %s", f"{self.args.report_name}.json")
+
+            pdf = report.create_lr_report(output)
+            pdf.output(f"{self.args.report_name}.pdf", 'F')
+            logger.info("Wrote pdf report to %s", f"{self.args.report_name}.pdf")
+        return output
+
+    def setup_example_data(self) -> None:
+        """Method to create example data and save (including config). Intended to allow users
+        to see how they would need to setup their own data.
+
+        Generates train and test data .csv files, train and test predictions .csv files and
+        a config.json file that can be used to run the attack from the command line.
+        """
+        X, y = load_breast_cancer(return_X_y=True)
+        train_X, test_X, train_y, test_y = train_test_split(
+            X, y, test_size=0.5, stratify=y
+        )
+        rf = RandomForestClassifier(min_samples_split=2, min_samples_leaf=1)
+        rf.fit(train_X, train_y)
+        train_data = np.hstack((train_X, train_y[:, None]))
+        np.savetxt("train_data.csv", train_data, delimiter=",")
+
+        test_data = np.hstack((test_X, test_y[:, None]))
+        np.savetxt("test_data.csv", test_data, delimiter=",")
+
+        train_preds = rf.predict_proba(train_X)
+        test_preds = rf.predict_proba(test_X)
+        np.savetxt("train_preds.csv", train_preds, delimiter=",")
+        np.savetxt("test_preds.csv", test_preds, delimiter=",")
+
+
+        config = {
+            "training_data_file": "train_data.csv",
+            "testing_data_file": "test_data.csv",
+            "training_preds_file": "train_preds.csv",
+            "testing_preds_file": "test_preds.csv",
+            "target_model": ["sklearn.ensemble", "RandomForestClassifier"],
+            "target_hyppars": {
+                "min_samples_split": 2,
+                "min_samples_leaf": 1
+            }
         }
-    }
 
-    with open('config.json', 'w', encoding='utf-8') as f:
-        f.write(json.dumps(config))
+        with open('config.json', 'w', encoding='utf-8') as f:
+            f.write(json.dumps(config))
+
+    def attack_from_config(self) -> None: # pylint: disable = too-many-locals
+        """Runs an attack based on the args parsed from the command line
+        """
+        logger = logging.getLogger("run-attack")
+        logger.info("Reading config from %s", self.args.json_file)
+        with open(self.args.json_file, encoding='utf-8') as f:
+            config = json.loads(f.read())
+
+        logger.info("Loading training data csv from %s", config['training_data_file'])
+        training_data = np.loadtxt(config['training_data_file'], delimiter=",")
+        train_X = training_data[:, :-1]
+        train_y = training_data[:, -1].flatten().astype(int)
+        logger.info("Loaded %d rows", len(train_X))
+
+        logger.info("Loading test data csv from %s", config['testing_data_file'])
+        test_data = np.loadtxt(config['testing_data_file'], delimiter=",")
+        test_X = test_data[:, :-1]
+        test_y = test_data[:, -1].flatten().astype(int)
+        logger.info("Loaded %d rows", len(test_X))
+
+        logger.info("Loading train predictions form %s", config['training_preds_file'])
+        train_preds = np.loadtxt(config['training_preds_file'], delimiter=",")
+        assert len(train_preds) == len(train_X)
+
+        logger.info("Loading test predictions form %s", config['testing_preds_file'])
+        test_preds = np.loadtxt(config['testing_preds_file'], delimiter=",")
+        assert len(test_preds) == len(test_X)
+
+        clf_module_name, clf_class_name = config['target_model']
+        module = importlib.import_module(clf_module_name)
+        clf_class = getattr(module, clf_class_name)
+        clf_params = config['target_hyppars']
+        clf = clf_class(**clf_params)
+        logger.info("Created model: %s", str(clf))
+        self.run_scenario_from_preds(
+            clf,
+            train_X,
+            train_y,
+            train_preds,
+            test_X,
+            test_y,
+            test_preds
+        )
+        logger.info("Computing metrics")
+
+
+# Methods invoked by command line script
+def _setup_example_data(args):
+    """Call the methods to setup some example data"""
+    lira_args = LIRAAttackArgs(**args.__dict__)
+    attack_obj = LIRAAttack(lira_args)
+    attack_obj.setup_example_data()
+
+def _example(args):
+    """Call the methods to run an example"""
+    lira_args = LIRAAttackArgs(**args.__dict__)
+    attack_obj = LIRAAttack(lira_args)
+    attack_obj.example()
+    attack_obj.make_report()
+
+def _run_attack(args):
+    """Run a command line attack based on saved files described in .json file"""
+    lira_args = LIRAAttackArgs(**args.__dict__)
+    attack_obj = LIRAAttack(lira_args)
+    attack_obj.attack_from_config()
+    attack_obj.make_report()
+
 
 def main():
     """Main method to parse args and invoke relevant code
@@ -373,11 +537,11 @@ def main():
     parser.add_argument('--report-name',
         type=str,
         action="store",
-        dest="pdf_name",
+        dest="report_name",
         required=False,
-        default="lr_report.pdf",
+        default="lr_report",
         help=(
-            'Output name for the .pdf report. Default = %(default)s'
+            'Output name for the report. Default = %(default)s'
         )
     )
     parser.add_argument('-p', '--p-thresh',
@@ -412,7 +576,8 @@ def main():
     args = parser.parse_args()
     try:
         args.func(args)
-    except AttributeError:
+    except AttributeError as e:
+        print(e)
         print("Invalid command. Try --help to get more details")
 
 
diff --git a/attacks/metrics.py b/attacks/metrics.py
index c969d8a3..5a5c2637 100644
--- a/attacks/metrics.py
+++ b/attacks/metrics.py
@@ -2,7 +2,7 @@
 Calculate metrics.
 '''
 
-from typing import Iterable, Tuple
+from collections.abc import Iterable
 import numpy as np
 from sklearn.metrics import confusion_matrix
 from sklearn.metrics import roc_auc_score
@@ -102,7 +102,7 @@ def _expected_auc_var(auc: float, num_pos: int, num_neg: int) -> float:
         (num_pos * num_neg)
     return var
 
-def auc_p_val(auc: float, n_pos: int, n_neg: int) -> Tuple[float, float]:
+def auc_p_val(auc: float, n_pos: int, n_neg: int) -> tuple[float, float]:
     """Compute the p-value for a given AUC
 
     Parameters
@@ -248,4 +248,7 @@ def get_metrics(clf, # pylint: disable = too-many-locals
     metrics['tpr'] = tpr
     metrics['roc_thresh'] = roc_thresh
 
+    metrics['n_pos_test_examples'] = y_test.sum()
+    metrics['n_neg_test_examples'] = (1 - y_test).sum()
+
     return metrics
diff --git a/attacks/mia_extremecase.py b/attacks/mia_extremecase.py
index bcbae0c4..55c4c28b 100644
--- a/attacks/mia_extremecase.py
+++ b/attacks/mia_extremecase.py
@@ -1,5 +1,4 @@
 """Method for computing extreme case metrics"""
-from typing import Tuple
 import numpy as np
 from scipy.stats import norm
 
@@ -7,7 +6,7 @@ def min_max_disc(
     y_true: np.ndarray,
     pred_probs: np.ndarray,
     x_prop: float=0.1,
-    log_p: bool=True) -> Tuple[float, float, float, float]: #pylint: disable = line-too-long
+    log_p: bool=True) -> tuple[float, float, float, float]: #pylint: disable = line-too-long
     """
     Non-average-case methods for MIA attacks. Considers actual frequency of membership
     amongst samples with highest- and lowest- assessed probability of membership. If an
diff --git a/attacks/report.py b/attacks/report.py
index 4d9f63b9..ec22d5ef 100644
--- a/attacks/report.py
+++ b/attacks/report.py
@@ -1,6 +1,5 @@
 """Code for automatic report generation"""
 import json
-from typing import List, Dict
 import pylab as plt
 import numpy as np
 from fpdf import FPDF
@@ -79,8 +78,13 @@ def default(self, o):
         """If an object is an np.ndarray, convert to list"""
         if isinstance(o, np.ndarray):
             return o.tolist()
+        if isinstance(o, np.int64):
+            return int(o)
+        if isinstance(o, np.int32):
+            return int(o)
         return json.JSONEncoder.default(self, o)
 
+
 def _write_dict(pdf, input_dict, indent=0, border=BORDER):
     """Write a dictionary to the pdf"""
     for key, value in input_dict.items():
@@ -91,20 +95,20 @@ def _write_dict(pdf, input_dict, indent=0, border=BORDER):
         pdf.multi_cell(150, 5, value, border, 'L')
         pdf.ln(h=5)
 
-def _title(pdf, text, border=BORDER, font_size=24, font_style='B'):
+def title(pdf, text, border=BORDER, font_size=24, font_style='B'):
     """Write a title block"""
     pdf.set_font('arial', font_style, font_size)
     pdf.ln(h=5)
     pdf.cell(0, 0, text, border, 1, 'C')
     pdf.ln(h=5)
 
-def _subtitle(pdf, text, indent=10, border=BORDER, font_size=12, font_style='B'): # pylint: disable = too-many-arguments
+def subtitle(pdf, text, indent=10, border=BORDER, font_size=12, font_style='B'): # pylint: disable = too-many-arguments
     """Write a subtitle block"""
     pdf.cell(indent, border=border)
     pdf.set_font('arial', font_style, font_size)
     pdf.cell(75, 10, text, border, 1)
 
-def _line(pdf, text, indent=0, border=BORDER, font_size=11, font_style='', font='arial'): # pylint: disable = too-many-arguments
+def line(pdf, text, indent=0, border=BORDER, font_size=11, font_style='', font='arial'): # pylint: disable = too-many-arguments
     """Write a standard block"""
     if indent > 0:
         pdf.cell(indent, border=border)
@@ -173,11 +177,7 @@ def _roc_plot(metrics, dummy_metrics, save_name):
     plt.grid()
     plt.savefig(save_name)
 
-def create_mia_report(
-    metadata: Dict,
-    mia_metrics: List,
-    dummy_metrics: List,
-    dummy_metadata: Dict) -> FPDF:
+def create_mia_report(attack_output: dict) -> FPDF:
     '''make a worst case membership inference report
 
     Parameters
@@ -203,6 +203,9 @@ def create_mia_report(
 
     '''
 
+    dummy_metrics = attack_output['dummy_attack_metrics']
+    mia_metrics = attack_output['attack_metrics']
+    metadata = attack_output['metadata']
     if dummy_metrics is None or len(dummy_metrics) == 0:
         do_dummy = False
     else:
@@ -214,22 +217,22 @@ def create_mia_report(
     pdf = FPDF()
     pdf.add_page()
     pdf.set_xy(0, 0)
-    _title(pdf, "WorstCase MIA attack result report")
-    _subtitle(pdf, "Introduction")
-    _line(pdf, INTRODUCTION)
-    _subtitle(pdf, "Experiment summary")
+    title(pdf, "WorstCase MIA attack result report")
+    subtitle(pdf, "Introduction")
+    line(pdf, INTRODUCTION)
+    subtitle(pdf, "Experiment summary")
     for key, value in metadata['experiment_details'].items():
-        _line(pdf, f"{key:>30s}: {str(value):30s}", font="courier")
-    _subtitle(pdf, "Global metrics")
+        line(pdf, f"{key:>30s}: {str(value):30s}", font="courier")
+    subtitle(pdf, "Global metrics")
     for key, value in metadata['global_metrics'].items():
-        _line(pdf, f"{key:>30s}: {str(value):30s}", font="courier")
+        line(pdf, f"{key:>30s}: {str(value):30s}", font="courier")
     if do_dummy:
-        _subtitle(pdf, "Baseline global metrics")
-        for key, value in dummy_metadata['global_metrics'].items():
-            _line(pdf, f"{key:>30s}: {str(value):30s}", font="courier")
+        subtitle(pdf, "Baseline global metrics")
+        for key, value in metadata['baseline_global_metrics'].items():
+            line(pdf, f"{key:>30s}: {str(value):30s}", font="courier")
 
-    _subtitle(pdf, "Metrics")
-    _line(
+    subtitle(pdf, "Metrics")
+    line(
         pdf,
         "The following show summaries of the attack metrics over the repetitions",
         font="arial"
@@ -242,11 +245,11 @@ def create_mia_report(
             f"{metric:>12} mean = {vals.mean():.2f}, var = {vals.var():.4f}, "
             f"min = {vals.min():.2f}, max = {vals.max():.2f}"
         )
-        _line(pdf, text, font='courier')
+        line(pdf, text, font='courier')
 
     if do_dummy:
-        _subtitle(pdf, "Baseline metrics")
-        _line(pdf, (
+        subtitle(pdf, "Baseline metrics")
+        line(pdf, (
             'The following show summaries of the attack metrics over the repetitions where there '
             'is no statistical difference between predictions in the training and test sets. '
             'Simulation was done with training and test set sizes equal to the real ones'
@@ -259,46 +262,49 @@ def create_mia_report(
                 f"{metric:>12} mean = {vals.mean():.2f}, var = {vals.var():.4f}, "
                 f"min = {vals.min():.2f}, max = {vals.max():.2f}"
             )
-            _line(pdf, text, font='courier')
+            line(pdf, text, font='courier')
 
     pdf.add_page()
-    _subtitle(pdf, "Log ROC")
+    subtitle(pdf, "Log ROC")
     pdf.image('log_roc.png', x = None, y = None, w = 0, h = 140, type = '', link = '')
     pdf.set_font('arial', '', 12)
-    _line(pdf, LOGROC_CAPTION)
+    line(pdf, LOGROC_CAPTION)
 
     pdf.add_page()
-    _title(pdf, "Glossary")
+    title(pdf, "Glossary")
     _write_dict(pdf, GLOSSARY)
 
 
     return pdf
 
-def create_json_report(mia_metrics, dummy_metrics):
+def create_json_report(output):
     '''Create a report in json format for injestion by other tools
     '''
     # Initial work, just dump mia_metrics and dummy_metrics into a json structure
-    json_payload = {'metrics': mia_metrics, 'baseline_metrics': dummy_metrics}
-    return json.dumps(json_payload, cls=NumpyArrayEncoder)
+    return json.dumps(output, cls=NumpyArrayEncoder)
 
-def create_lr_report(metadata, mia_metrics):
+def create_lr_report(output):
     """TODO"""
+    mia_metrics = output['attack_metrics'][0]
+    metadata = output['metadata']
     _roc_plot_single(mia_metrics, "log_roc.png")
     pdf = FPDF()
     pdf.add_page()
     pdf.set_xy(0, 0)
-    _title(pdf, "Likelihood Ratio Attack Report")
-    _subtitle(pdf, "Introduction")
-    _subtitle(pdf, "Metadata")
-    for key, value in metadata.items():
-        _line(pdf, f"{key:>30s}: {str(value):30s}", font="courier")
-    _subtitle(pdf, "Metrics")
+    title(pdf, "Likelihood Ratio Attack Report")
+    subtitle(pdf, "Introduction")
+    subtitle(pdf, "Metadata")
+    for key, value in metadata['experiment_details'].items():
+        line(pdf, f"{key:>30s}: {str(value):30s}", font="courier")
+    for key, value in metadata['global_metrics'].items():
+        line(pdf, f"{key:>30s}: {str(value):30s}", font="courier")
+    subtitle(pdf, "Metrics")
     sub_metrics_dict = {key: val for key, val in mia_metrics.items() if isinstance(val, float)}
     for key, value in sub_metrics_dict.items():
         if key in MAPPINGS:
             value = MAPPINGS[key](value)
-        _line(pdf, f"{key:>30s}: {value:.4f}", font="courier")
+        line(pdf, f"{key:>30s}: {value:.4f}", font="courier")
     pdf.add_page()
-    _subtitle(pdf, "ROC Curve")
+    subtitle(pdf, "ROC Curve")
     pdf.image('log_roc.png', x = None, y = None, w = 0, h = 140, type = '', link = '')
     return pdf
diff --git a/attacks/worst_case_attack.py b/attacks/worst_case_attack.py
index e92049f3..560716cb 100644
--- a/attacks/worst_case_attack.py
+++ b/attacks/worst_case_attack.py
@@ -7,272 +7,404 @@
 import argparse
 import logging
 
-from typing import Tuple, Dict
+from typing import Any
+
+from collections.abc import Hashable
 
 import numpy as np
+import sklearn
 from sklearn.ensemble import RandomForestClassifier
 from sklearn.model_selection import train_test_split
 
 from attacks import metrics
 from attacks import report
+from attacks.attack import Attack
+from attacks.dataset import Data
 
 logging.basicConfig(level=logging.INFO)
 
 P_THRESH = 0.05
 
-def _generate_array(n_rows: int, beta: float) -> np.ndarray:
-    """Generate a single array of predictions, used when doing baseline experiments
-
-    Parameters
-    ----------
-    n_rows: int
-        the number of rows worth of data to generate
-    beta: float
-        the beta parameter for sampling probabilities
-
-    Returns
-    -------
-    preds: np.ndarray
-        Array of predictions. Two columns, n_rows rows
-
-    Notes
-    -----
-
-    Examples
-    --------
-
-    """
-
-    preds = np.zeros((n_rows, 2), float)
-    for row_idx in range(n_rows):
-        train_class = np.random.choice(2)
-        train_prob = np.random.beta(1, beta)
-        preds[row_idx, train_class] = train_prob
-        preds[row_idx, 1 - train_class] = 1 - train_prob
-    return preds
-
-def generate_arrays(
-    n_rows_in: int,
-    n_rows_out: int,
-    train_beta: float=2,
-    test_beta: float=2) -> Tuple[np.ndarray, np.ndarray]:
-    """Generate train and test prediction arrays, used when computing baseline
-
-    Parameters
-    ----------
-    n_rows_in: int
-        number of rows of in-sample (training) probabilities
-    n_rows_out: int
-        number of rows of out-of-sample (testing) probabilities
-    train_beta: float
-        beta value for generating train probabilities
-    test_beta: float:
-        beta_value for generating test probabilities
-
-    Returns
-    -------
-    train_preds: np.ndarray
-        Array of train predictions (n_rows x 2 columns)
-    test_preds: np.ndarray
-        Array of test predictions (n_rows x 2 columns)
-    """
-    train_preds = _generate_array(n_rows_in, train_beta)
-    test_preds = _generate_array(n_rows_out, test_beta)
-    return train_preds, test_preds
-
-def _make_dummy_data(args: Dict) -> None:
-    """Makes dummy data for testing functionality
-
-    Parameters
-    ----------
-    args: dict
-        Command line arguments
-
-    Returns
-    -------
-
-    Notes
-    -----
-
-    Returns nothing but saves two .csv files
-    """
-    logger = logging.getLogger("dummy-data")
-    logger.info("Making dummy data with %d rows in and %d out", args.n_rows_in, args.n_rows_out)
-    logger.info("Generating rows")
-    train_preds, test_preds = generate_arrays(
-        args.n_rows_in,
-        args.n_rows_out,
-        train_beta=args.train_beta,
-        test_beta=args.test_beta
-    )
-    logger.info("Saving files")
-    np.savetxt("train_preds.csv", train_preds, delimiter=",")
-    np.savetxt("test_preds.csv", test_preds, delimiter=",")
-
-def _run_attack(args, make_report=True):
-    """Method that runs a worst case attack
-    """
-    logger = logging.getLogger("run-attack")
-    logger.info("Loading train predictions form %s", args.in_sample_filename)
-    train_preds = np.loadtxt(args.in_sample_filename, delimiter=",")
-    logger.info("Loaded %d rows", len(train_preds))
-    logger.info("Loading test predictions form %s", args.in_sample_filename)
-    test_preds = np.loadtxt(args.out_sample_filename, delimiter=",")
-    logger.info("Loaded %d rows", len(test_preds))
-    mia_metrics, metadata = attack(args, train_preds, test_preds)
-
-    # do some baseline attacks
-    logger.info("Performing baseline attacks")
-
-    dummy_metrics = []
-    dummy_metadata = None
-    for _ in range(args.dummy_reps):
-        dummy_train, dummy_test = generate_arrays(len(train_preds), len(test_preds))
-        temp_dummy_metrics, dummy_metadata = attack(args, dummy_train, dummy_test)
-        dummy_metrics += temp_dummy_metrics
-
-    if args.dummy_reps > 0:
-        metadata['experiment_details']['n_baseline_experiments_done'] = (
-            f'Done {args.n_reps} reps for {args.dummy_reps} splits of synthetic '
-            f'data (total = {len(dummy_metrics)})'
+class WorstCaseAttackArgs:
+    """Arguments for worst case"""
+    def __init__(self, **kwargs):
+        self.__dict__['n_reps'] = 10
+        self.__dict__['p_thresh'] = 0.05
+        self.__dict__['n_dummy_reps'] = 1
+        self.__dict__['train_beta'] = 2
+        self.__dict__['test_beta'] = 2
+        self.__dict__['test_prop'] = 0.3
+        self.__dict__['n_rows_in'] = 1000
+        self.__dict__['n_rows_out'] = 1000
+        self.__dict__['in_sample_filename'] = None
+        self.__dict__['out_sample_filename'] = None
+        self.__dict__['report_name'] = None
+        self.__dict__.update(kwargs)
+
+    def __str__(self):
+        return ",".join([f"{str(key)}: {str(value)}" for key, value in self.__dict__.items()])
+
+    def set_param(self, key: Hashable, value: Any) -> None:
+        """Set a parameter"""
+        self.__dict__[key] = value
+
+    def get_args(self) -> dict:
+        """Return arguments"""
+        return self.__dict__
+
+class WorstCaseAttack(Attack):
+    """Class to wrap the worst case attack code"""
+    def __init__(self, args: WorstCaseAttackArgs=WorstCaseAttackArgs()):
+        self.attack_metrics = None
+        self.dummy_attack_metrics = None
+        self.metadata = None
+        self.args = args
+
+    def __str__(self):
+        return "WorstCase attack"
+
+    def attack(
+        self,
+        dataset: Data,
+        target_model: sklearn.base.BaseEstimator) -> None:
+        """Programmatic attack entry point
+
+        To be used when code has access to data class and trained target model
+
+        Parameters
+        ----------
+        dataset: attacks.dataset.Data
+            dataset as a Data class object
+        target_model: sklearn.base.BaseEstimator
+            target model that inherits from an sklearn BaseEstimator
+        """
+        train_preds = target_model.predict_proba(dataset.x_train)
+        test_preds = target_model.predict_proba(dataset.x_test)
+        self.attack_from_preds(train_preds, test_preds)
+
+    def attack_from_prediction_files(self):
+        """Start an attack from saved prediction files
+
+        To be used when only saved predictions are available.
+
+        Filenames for the saved prediction files to be specified in the arguments provided
+        in the constructor
+        """
+        train_preds = np.loadtxt(self.args.in_sample_filename, delimiter=",")
+        test_preds = np.loadtxt(self.args.out_sample_filename, delimiter=",")
+        self.attack_from_preds(train_preds, test_preds)
+
+    def attack_from_preds( # pylint: disable=too-many-locals
+        self,
+        train_preds: np.ndarray,
+        test_preds: np.ndarray) -> None:
+        """
+        Runs the attack based upon the predictions in train_preds and test_preds, and the params
+        stored in self.args
+
+        Parameters
+        ----------
+
+        train_preds: np.ndarray
+            Array of train predictions. One row per example, one column per class (i.e. 2)
+        test_preds:  np.ndarray
+            Array of test predictions. One row per example, one column per class (i.e. 2)
+
+        """
+        logger = logging.getLogger("attack-from-preds")
+        logger.info("Running main attack repetitions")
+        self.attack_metrics = self.run_attack_reps(train_preds, test_preds)
+        if self.args.n_dummy_reps > 0:
+            logger.info("Running dummy attack reps")
+            self.dummy_attack_metrics = []
+            n_train_rows = len(train_preds)
+            n_test_rows = len(test_preds)
+            for _ in range(self.args.n_dummy_reps):
+                d_train_preds, d_test_preds = self.generate_arrays(
+                    n_train_rows,
+                    n_test_rows,
+                    self.args.train_beta,
+                    self.args.test_beta
+                )
+                temp_metrics = self.run_attack_reps(d_train_preds, d_test_preds)
+                self.dummy_attack_metrics += temp_metrics
+        logger.info("Finished running attacks")
+
+
+    def run_attack_reps(self, train_preds: np.ndarray, test_preds: np.ndarray) -> list:
+        """
+        Run actual attack reps from train and test predictions
+
+        Parameters
+        ----------
+        train_preds: np.ndarray
+            predictions from the model on training (in-sample) data
+        test_preds: np.ndarray
+            predictions from the model on testing (out-of-sample) data
+
+        Returns
+        -------
+        mia_metrics: List
+            List of attack metrics dictionaries, one for each repetition
+        """
+        self.args.set_param('n_rows_in', len(train_preds))
+        self.args.set_param('n_rows_out', len(test_preds))
+        logger = logging.getLogger("attack-reps")
+        logger.info("Sorting probabilities to leave highest value in first column")
+        train_preds = -np.sort(-train_preds, axis=1)
+        test_preds = -np.sort(-test_preds, axis=1)
+
+        logger.info("Creating MIA data")
+        mi_x = np.vstack((train_preds, test_preds))
+        mi_y = np.hstack(
+            (
+                np.ones(len(train_preds)),
+                np.zeros(len(test_preds))
+            )
         )
-    else:
-        metadata['experiment_details']['n_baseline_experiments_done'] = "None"
-
-    if make_report:
-        pdf = report.create_mia_report(metadata, mia_metrics, dummy_metrics, dummy_metadata)
-        pdf.output(f"{args.report_name}.pdf", 'F')
-        json_details = report.create_json_report(mia_metrics, dummy_metrics)
-        with open(f"{args.report_name}.json", 'w', encoding="utf-8") as json_file:
-            json_file.write(json_details)
-
-def _get_n_significant(p_val_list, p_thresh, bh_fdr_correction=False):
-    '''
-    Helper method to determine if values within a list of p-values are significant at
-    p_thresh. Can perform multiple testing correction.
-    '''
-    if not bh_fdr_correction:
-        return sum([1 for p in p_val_list if p <= p_thresh]) # pylint: disable = consider-using-generator
-    p_val_list = np.asarray(sorted(p_val_list))
-    n_vals = len(p_val_list)
-    hoch_vals = np.array(
-        [(k / n_vals) * P_THRESH for k in range(1, n_vals + 1)]
-    )
-    bh_sig_list = p_val_list <= hoch_vals
-    if any(bh_sig_list):
-        n_sig_bh = (np.where(bh_sig_list)[0]).max() + 1
-    else:
-        n_sig_bh = 0
-    return n_sig_bh
-
-
-def attack( # pylint: disable=too-many-locals
-    args: Dict,
-    train_preds: np.ndarray,
-    test_preds: np.ndarray) -> Tuple[dict, dict]:
-    """Runs the attack based upon the predictions in train_preds and test_preds, and params
-    in args
-
-    Parameters
-    ----------
-
-    args: dict
-        Command line arguments
-    train_preds: np.ndarray
-        Array of train predictions. One row per example, one column per class (i.e. 2)
-    test_preds:  np.ndarray
-        Array of test predictions. One row per example, one column per class (i.e. 2)
-
-    Returns
-    -------
-    mia_metrics: dict
-        dictionary of metrics
-    metadata: dict
-        dictionary of metadata
-
-    """
-    logger = logging.getLogger("attack")
-    logger.info("Sorting probabilities to leave highest value in first column")
-    train_preds = -np.sort(-train_preds, axis=1)
-    test_preds = -np.sort(-test_preds, axis=1)
-
-    logger.info("Creating MIA data")
-    mi_x = np.vstack((train_preds, test_preds))
-    mi_y = np.hstack(
-        (
-            np.ones(len(train_preds)),
-            np.zeros(len(test_preds))
+
+        mia_metrics = []
+        for rep in range(self.args.n_reps):
+            logger.info("Rep %d of %d", rep, self.args.n_reps)
+            mi_train_x, mi_test_x, mi_train_y, mi_test_y = train_test_split(
+                mi_x, mi_y, test_size=self.args.test_prop, stratify=mi_y
+            )
+            attack_classifier = RandomForestClassifier()
+            attack_classifier.fit(mi_train_x, mi_train_y)
+
+            mia_metrics.append(
+                metrics.get_metrics(
+                    attack_classifier,
+                    mi_test_x,
+                    mi_test_y
+                )
+            )
+
+        logger.info("Finished simulating attacks")
+
+        return mia_metrics
+
+
+
+    def _get_global_metrics(self, attack_metrics: list) -> dict:
+        """Summarise metrics from a metric list
+
+        Arguments
+        ---------
+        attack_metrics: List
+            list of attack metrics dictionaries
+
+        Returns
+        -------
+        global_metrics: Dict
+            Dictionary of summary metrics
+
+        """
+        global_metrics = {}
+        auc_p_vals = [
+            metrics.auc_p_val(
+                m['AUC'],
+                m['n_pos_test_examples'],
+                m['n_neg_test_examples']
+            )[0] for m in attack_metrics
+        ]
+
+        m = attack_metrics[0]
+        _, auc_std = metrics.auc_p_val(
+            0.5,
+            m['n_pos_test_examples'],
+            m['n_neg_test_examples']
         )
-    )
 
-    mia_metrics = []
-    for rep in range(args.n_reps):
-        logger.info("Rep %d of %d", rep, args.n_reps)
-        mi_train_x, mi_test_x, mi_train_y, mi_test_y = train_test_split(
-            mi_x, mi_y, test_size=args.test_prop, stratify=mi_y
+        global_metrics['null_auc_3sd_range'] = \
+            f"{0.5 - 3*auc_std:.4f} -> {0.5 + 3*auc_std:.4f}"
+        global_metrics['n_sig_auc_p_vals'] = self._get_n_significant(auc_p_vals, self.args.p_thresh)
+        global_metrics['n_sig_auc_p_vals_corrected'] = self._get_n_significant(
+            auc_p_vals,
+            self.args.p_thresh,
+            bh_fdr_correction=True
         )
-        attack_classifier = RandomForestClassifier()
-        attack_classifier.fit(mi_train_x, mi_train_y)
-
-        mia_metrics.append(
-            metrics.get_metrics(
-                attack_classifier,
-                mi_test_x,
-                mi_test_y
-            )
+
+        pdif_vals = [np.exp(-m['PDIF01']) for m in attack_metrics]
+        global_metrics['n_sig_pdif_vals'] = self._get_n_significant(pdif_vals, self.args.p_thresh)
+        global_metrics['n_sig_pdif_vals_corrected'] = self._get_n_significant(
+            pdif_vals,
+            self.args.p_thresh,
+            bh_fdr_correction=True
         )
 
-    logger.info("Finished simulating attacks")
-    metadata = {
-        'global_metrics': {},
-        'experiment_details': {
-            'n_reps': args.n_reps,
-            'n_in_sample': len(train_preds),
-            'n_out_sample': len(test_preds),
-            'attack model': "Random Forest",
-            'in_sample_filename': args.in_sample_filename,
-            'out_sample_filename': args.out_sample_filename,
-            'p_val_thresh': args.p_thresh
-        }
-    }
-
-    auc_p_vals = [
-        metrics.auc_p_val(
-            m['AUC'],
-            mi_test_y.sum(),
-            len(mi_test_y) - mi_test_y.sum()
-        )[0] for m in mia_metrics
-    ]
-
-    _, auc_std = metrics.auc_p_val(
-        0.5,
-        mi_test_y.sum(),
-        len(mi_test_y) - mi_test_y.sum()
-    )
+        return global_metrics
 
-    metadata['global_metrics']['null_auc_3sd_range'] = \
-        f"{0.5 - 3*auc_std:.4f} -> {0.5 + 3*auc_std:.4f}"
-    metadata['global_metrics']['n_sig_auc_p_vals'] = _get_n_significant(auc_p_vals, args.p_thresh)
-    metadata['global_metrics']['n_sig_auc_p_vals_corrected'] = _get_n_significant(
-        auc_p_vals,
-        args.p_thresh,
-        bh_fdr_correction=True
-    )
 
-    pdif_vals = [np.exp(-m['PDIF01']) for m in mia_metrics]
-    metadata['global_metrics']['n_sig_pdif_vals'] = _get_n_significant(pdif_vals, args.p_thresh)
-    metadata['global_metrics']['n_sig_pdif_vals_corrected'] = _get_n_significant(
-        pdif_vals,
-        args.p_thresh,
-        bh_fdr_correction=True
-    )
+    def _get_n_significant(self, p_val_list, p_thresh, bh_fdr_correction=False):
+        '''
+        Helper method to determine if values within a list of p-values are significant at
+        p_thresh. Can perform multiple testing correction.
+        '''
+        if not bh_fdr_correction:
+            return sum(1 for p in p_val_list if p <= p_thresh) # pylint: disable = consider-using-generator
+        p_val_list = np.asarray(sorted(p_val_list))
+        n_vals = len(p_val_list)
+        hoch_vals = np.array(
+            [(k / n_vals) * P_THRESH for k in range(1, n_vals + 1)]
+        )
+        bh_sig_list = p_val_list <= hoch_vals
+        if any(bh_sig_list):
+            n_sig_bh = (np.where(bh_sig_list)[0]).max() + 1
+        else:
+            n_sig_bh = 0
+        return n_sig_bh
+
+
+    def _generate_array(self, n_rows: int, beta: float) -> np.ndarray:
+        """Generate a single array of predictions, used when doing baseline experiments
+
+        Parameters
+        ----------
+        n_rows: int
+            the number of rows worth of data to generate
+        beta: float
+            the beta parameter for sampling probabilities
+
+        Returns
+        -------
+        preds: np.ndarray
+            Array of predictions. Two columns, n_rows rows
+
+        Notes
+        -----
+
+        Examples
+        --------
+
+        """
+
+        preds = np.zeros((n_rows, 2), float)
+        for row_idx in range(n_rows):
+            train_class = np.random.choice(2)
+            train_prob = np.random.beta(1, beta)
+            preds[row_idx, train_class] = train_prob
+            preds[row_idx, 1 - train_class] = 1 - train_prob
+        return preds
+
+    def generate_arrays(
+        self,
+        n_rows_in: int,
+        n_rows_out: int,
+        train_beta: float=2,
+        test_beta: float=2) -> tuple[np.ndarray, np.ndarray]:
+        """Generate train and test prediction arrays, used when computing baseline
+
+        Parameters
+        ----------
+        n_rows_in: int
+            number of rows of in-sample (training) probabilities
+        n_rows_out: int
+            number of rows of out-of-sample (testing) probabilities
+        train_beta: float
+            beta value for generating train probabilities
+        test_beta: float:
+            beta_value for generating test probabilities
+
+        Returns
+        -------
+        train_preds: np.ndarray
+            Array of train predictions (n_rows x 2 columns)
+        test_preds: np.ndarray
+            Array of test predictions (n_rows x 2 columns)
+        """
+        train_preds = self._generate_array(n_rows_in, train_beta)
+        test_preds = self._generate_array(n_rows_out, test_beta)
+        return train_preds, test_preds
+
+    def make_dummy_data(self) -> None:
+        """Makes dummy data for testing functionality
+
+        Parameters
+        ----------
+        args: dict
+            Command line arguments
+
+        Returns
+        -------
+
+        Notes
+        -----
+
+        Returns nothing but saves two .csv files
+        """
+        logger = logging.getLogger("dummy-data")
+        logger.info(
+            "Making dummy data with %d rows in and %d out",
+            self.args.n_rows_in,
+            self.args.n_rows_out
+        )
+        logger.info("Generating rows")
+        train_preds, test_preds = self.generate_arrays(
+            self.args.n_rows_in,
+            self.args.n_rows_out,
+            train_beta=self.args.train_beta,
+            test_beta=self.args.test_beta
+        )
+        logger.info("Saving files")
+        np.savetxt(self.args.in_sample_filename, train_preds, delimiter=",")
+        np.savetxt(self.args.out_sample_filename, test_preds, delimiter=",")
+
+
+    def _construct_metadata(self):
+        """Constructs the metadata object, after attacks"""
+        self.metadata = {}
+        # Store all args
+        self.metadata['experiment_details'] = {}
+        self.metadata['experiment_details'].update(self.args.__dict__)
+        if 'func' in self.metadata['experiment_details']:
+            del self.metadata['experiment_details']['func']
+
+        self.metadata['experiment_details']['attack'] = str(self)
+
+        # Global metrics
+        self.metadata['global_metrics'] = self._get_global_metrics(self.attack_metrics)
+        self.metadata['baseline_global_metrics'] = self._get_global_metrics(
+            self.dummy_attack_metrics
+        )
+
+
+    def make_report(self) -> dict:
+        """Creates output dictionary structure"""
+        output = {}
+        output['attack_metrics'] = self.attack_metrics
+        output['dummy_attack_metrics'] = self.dummy_attack_metrics
+        self._construct_metadata()
+        output['metadata'] = self.metadata
+        if self.args.report_name is not None:
+            json_report = report.create_json_report(output)
+            with open(f"{self.args.report_name}.json", 'w', encoding='utf-8') as f:
+                f.write(json_report)
+
+            pdf = report.create_mia_report(output)
+            pdf.output(f"{self.args.report_name}.pdf", 'F')
 
-    return mia_metrics, metadata
+        return output
+
+def _make_dummy_data(args):
+    """Initialise class and run dummy data creation"""
+    wc_args = WorstCaseAttackArgs(**args.__dict__)
+    wc_args.set_param("in_sample_filename", "train_preds.csv")
+    wc_args.set_param("out_sample_filename", "test_preds.csv")
+    attack_obj = WorstCaseAttack(wc_args)
+    attack_obj.make_dummy_data()
+
+def _run_attack(args):
+    """Initialise class and run attack from prediction files"""
+    wc_args = WorstCaseAttackArgs(**args.__dict__)
+    attack_obj = WorstCaseAttack(wc_args)
+    attack_obj.attack_from_prediction_files()
+    _ = attack_obj.make_report()
 
 
 def main():
     '''main method to parse arguments and invoke relevant method'''
-
+    logger = logging.getLogger("main")
     parser = argparse.ArgumentParser(description=(
         'Perform a worst case attack from saved model predictions'
         )
@@ -285,7 +417,7 @@ def main():
         dest='n_rows_in',
         type=int,
         required=False,
-        default=100,
+        default=1000,
         help=(
             'How many rows to generate in the in-sample file. Default = %(default)d'
         )
@@ -296,7 +428,7 @@ def main():
         dest='n_rows_out',
         type=int,
         required=False,
-        default=100,
+        default=1000,
         help=(
             'How many rows to generate in the out-of-sample file. Default = %(default)d'
         )
@@ -384,17 +516,17 @@ def main():
         type=str,
         action='store',
         dest='report_name',
-        default='mia_report',
+        default='worstcase_report',
         required=False,
         help=(
             'Filename for the report output. Default = %(default)s. Code will append .pdf and .json'
         )
     )
 
-    attack_parser.add_argument('--dummy-reps',
+    attack_parser.add_argument('--n-dummy-reps',
         type=int,
         action='store',
-        dest='dummy_reps',
+        dest='n_dummy_reps',
         default=1,
         required=False,
         help=(
@@ -418,11 +550,12 @@ def main():
 
 
     args = parser.parse_args()
-    print(args)
+
     try:
         args.func(args)
-    except AttributeError:
-        print("Invalid command. Try --help to get more details")
+    except AttributeError as e:
+        logger.error("Invalid command. Try --help to get more details")
+        logger.error(e)
 
 if __name__ == '__main__':
     main()
diff --git a/examples/attribute_inference_example.py b/examples/attribute_inference_example.py
new file mode 100644
index 00000000..5f44a047
--- /dev/null
+++ b/examples/attribute_inference_example.py
@@ -0,0 +1,99 @@
+"""
+Example demonstrating the attribute inference attacks.
+
+Running
+-------
+
+Invoke this code from the root AI-SDC folder with
+python -m examples.attribute_inference_example
+
+"""
+
+import numpy as np
+from sklearn.datasets import fetch_openml
+from sklearn.ensemble import RandomForestClassifier
+from sklearn.model_selection import train_test_split
+from sklearn.preprocessing import LabelEncoder, OneHotEncoder
+
+from attacks import attribute_attack, dataset  # pylint: disable = import-error
+
+# pylint: disable = duplicate-code
+
+if __name__ == "__main__":
+
+    # [Researcher] Access a dataset
+    nursery_data = fetch_openml(data_id=26, as_frame=True)
+    x = np.asarray(nursery_data.data, dtype=str)
+    y = np.asarray(nursery_data.target, dtype=str)
+    n_features = np.shape(x)[1]
+    indices: list[list[int]] = [
+        [0, 1, 2],  # parents
+        [3, 4, 5, 6, 7],  # has_nurs
+        [8, 9, 10, 11],  # form
+        [12, 13, 14, 15],  # children
+        [16, 17, 18],  # housing
+        [19, 20],  # finance
+        [21, 22, 23],  # social
+        [24, 25, 26],  # health
+    ]
+
+    # [Researcher] Split into training and test sets
+    # target model train / test split - these are strings
+    (x_train_orig, x_test_orig, y_train_orig, y_test_orig,) = train_test_split(
+        x,
+        y,
+        test_size=0.5,
+        stratify=y,
+        shuffle=True,
+    )
+
+    # [Researcher] Preprocess dataset
+    # one-hot encoding of features and integer encoding of labels
+    label_enc = LabelEncoder()
+    feature_enc = OneHotEncoder()
+    x_train = feature_enc.fit_transform(x_train_orig).toarray()
+    y_train = label_enc.fit_transform(y_train_orig)
+    x_test = feature_enc.transform(x_test_orig).toarray()
+    y_test = label_enc.transform(y_test_orig)
+
+    # [TRE / Researcher] Wrap the data in a dataset object
+    data = dataset.Data()
+    data.name = "nursery"
+    data.add_processed_data(x_train, y_train, x_test, y_test)
+    data.add_raw_data(x, y, x_train_orig, y_train_orig, x_test_orig, y_test_orig)
+    for i in range(n_features):
+        data.add_feature(nursery_data.feature_names[i], indices[i], "onehot")
+
+    print(f"Dataset: {data.name}")
+    print(f"Features: {data.features}")
+    print(f"x_train shape = {np.shape(data.x_train)}")
+    print(f"y_train shape = {np.shape(data.y_train)}")
+    print(f"x_test shape = {np.shape(data.x_test)}")
+    print(f"y_test shape = {np.shape(data.y_test)}")
+
+    # [Researcher] Define the classifier
+    model = RandomForestClassifier(bootstrap=False)
+
+    # [Researcher] Train the classifier
+    model.fit(data.x_train, data.y_train)
+    acc_train = model.score(data.x_train, data.y_train)
+    acc_test = model.score(data.x_test, data.y_test)
+    print(f"Base model train accuracy: {acc_train}")
+    print(f"Base model test accuracy: {acc_test}")
+
+    # [TRE] Define some attack parameters
+    attack_args = attribute_attack.AttributeAttackArgs(n_cpu=7, report_name="aia_report")
+
+    # [TRE] Create the attack object
+    attack_obj = attribute_attack.AttributeAttack(attack_args)
+
+    # [TRE] Run the attack
+    attack_obj.attack(data, model)
+
+    # [TRE] Grab the output
+    output = attack_obj.make_report() # also makes .pdf and .json files
+    output = output["attack_metrics"]
+
+    # [TRE] explore the metrics
+    print(attribute_attack.report_categorical(output))
+    print(attribute_attack.report_quantitative(output))
diff --git a/examples/lira_attack_example.py b/examples/lira_attack_example.py
index 6d770ef0..a04a847e 100644
--- a/examples/lira_attack_example.py
+++ b/examples/lira_attack_example.py
@@ -40,8 +40,8 @@
 from sklearn.ensemble import RandomForestClassifier
 from sklearn.datasets import load_breast_cancer
 
-from attacks import likelihood_attack # pylint: disable = import-error
-from attacks import metrics # pylint: disable = import-error
+from attacks.likelihood_attack import LIRAAttackArgs, LIRAAttack # pylint: disable = import-error
+from attacks.dataset import Data # pylint: disable = import-error
 
 # [Researcher] Access a dataset
 X, y = load_breast_cancer(return_X_y=True, as_frame=False)
@@ -55,44 +55,35 @@
 target_model.fit(train_X, train_y)
 
 # [Researcher] Provide the model and the train and test data to the TRE
+dataset = Data()
+dataset.add_processed_data(train_X, train_y, test_X, test_y)
 
-# [TRE] Compute the predictions on the training and test sets
-train_preds = target_model.predict_proba(train_X)
-test_preds = target_model.predict_proba(test_X)
-
-# [TRE] Create a shadow model
-shadow_clf = RandomForestClassifier(min_samples_split=2, min_samples_leaf=1)
-
-# [TRE] Call attack code
-attack_scores, attack_labels, attack_classifier = likelihood_attack.likelihood_scenario(
-    shadow_clf,
-    train_X,
-    train_y,
-    train_preds,
-    test_X,
-    test_y,
-    test_preds,
-    n_shadow_models=50
-)
+# [TRE] sets up the attack
+args = LIRAAttackArgs(n_shadow_models=100, report_name="lira_example_report")
+attack_obj = LIRAAttack(args)
 
+# [TRE] runs the attack
+attack_obj.attack(dataset, target_model)
 
-# [TRE] Computes attack metrics
-attack_metrics = metrics.get_metrics(
-    attack_classifier,
-    attack_scores,
-    attack_labels
-)
+# [TRE] Get the output
+output = attack_obj.make_report() # also makes .pdf and .json files
+
+# [TRE] Accesses attack metrics and metadata
+attack_metrics = output['attack_metrics'][0]
+metadata = output['metadata']
 
 # [TRE] Looks at the metric values
-print(f"AUC: {attack_metrics['AUC']:.3f}")
-auc_p, _ = metrics.auc_p_val(
-    attack_metrics['AUC'],
-    attack_labels.sum(),
-    len(attack_labels) - attack_labels.sum()
-)
-print(f"AUC p value: {auc_p:.3f}")
-print(f"FDIF01: {attack_metrics['FDIF01']:.3f}")
-print(f"PDIF01: {np.exp(-attack_metrics['PDIF01']):.3f}")
+print("Attack metrics:")
+for key, value in attack_metrics.items():
+    try:
+        print(key, f"{value:.3f}")
+    except TypeError:
+        print(f"Cannot print {key}")
+
+print("Global metrics")
+for key, value in metadata['global_metrics'].items():
+    print(key, value)
+
 
 print("Programmatic example finished")
 print("****************************")
@@ -105,8 +96,8 @@
 # the command line rather than programmatically
 
 # [Researcher] Dump the training and test predictions to .csv files
-np.savetxt("train_preds.csv", train_preds, delimiter=",")
-np.savetxt("test_preds.csv", test_preds, delimiter=",")
+np.savetxt("train_preds.csv", target_model.predict_proba(train_X), delimiter=",")
+np.savetxt("test_preds.csv", target_model.predict_proba(test_X), delimiter=",")
 
 # [Researcher] Dump the training and test data to a .csv file
 np.savetxt(
@@ -146,8 +137,8 @@
     (
         "python -m attacks.likelihood_attack run-attack "
         "--json-file config.json "
-        "--report-name example_lira_report.pdf "
-        "--n-shadow-models 50"
+        "--report-name example_lira_report "
+        "--n-shadow-models 100"
     )
 )
 
diff --git a/examples/safemodel_attack_integration_bothcalls.py b/examples/safemodel_attack_integration_bothcalls.py
new file mode 100644
index 00000000..31ffbd13
--- /dev/null
+++ b/examples/safemodel_attack_integration_bothcalls.py
@@ -0,0 +1,92 @@
+""" workimg on how to integrate attacks into safemosdel classes
+Invoke this code from the root AI-SDC folder with
+python -m examples.test_sagfemodel_attack_integration
+
+"""
+
+import numpy as np
+
+from sklearn.datasets import fetch_openml
+from sklearn.model_selection import train_test_split
+from sklearn.preprocessing import LabelEncoder, OneHotEncoder
+
+from safemodel.classifiers import SafeDecisionTreeClassifier
+from attacks.dataset import Data
+
+if __name__ == "__main__":
+
+    # [Researcher] Access a dataset
+    nursery_data = fetch_openml(data_id=26, as_frame=True)
+    x = np.asarray(nursery_data.data, dtype=str)
+    y = np.asarray(nursery_data.target, dtype=str)
+
+    n_features = np.shape(x)[1]
+    indices: list[list[int]] = [
+        [0, 1, 2],  # parents
+        [3, 4, 5, 6, 7],  # has_nurs
+        [8, 9, 10, 11],  # form
+        [12, 13, 14, 15],  # children
+        [16, 17, 18],  # housing
+        [19, 20],  # finance
+        [21, 22, 23],  # social
+        [24, 25, 26],  # health
+    ]
+
+    # [Researcher] Split into training and test sets
+    # target model train / test split - these are strings
+    (x_train_orig, x_test_orig, y_train_orig, y_test_orig,) = train_test_split(
+        x,
+        y,
+        test_size=0.5,
+        stratify=y,
+        shuffle=True,
+    )
+
+    # [Researcher] Preprocess dataset
+    # one-hot encoding of features and integer encoding of labels
+    label_enc = LabelEncoder()
+    feature_enc = OneHotEncoder()
+    x_train = feature_enc.fit_transform(x_train_orig).toarray()
+    y_train = label_enc.fit_transform(y_train_orig)
+    x_test = feature_enc.transform(x_test_orig).toarray()
+    y_test = label_enc.transform(y_test_orig)
+
+    # [TRE / Researcher] Wrap the data in a dataset object
+    the_data = Data()
+    the_data.name = "nursery"
+    the_data.add_processed_data(x_train, y_train, x_test, y_test)
+    the_data.add_raw_data(x, y, x_train_orig, y_train_orig, x_test_orig, y_test_orig)
+    for i in range(n_features):
+        the_data.add_feature(nursery_data.feature_names[i], indices[i], "onehot")
+
+    print(f"Dataset: {the_data.name}")
+    print(f"Features: {the_data.features}")
+    print(f"x_train shape = {np.shape(the_data.x_train)}")
+    print(f"y_train shape = {np.shape(the_data.y_train)}")
+    print(f"x_test shape = {np.shape(the_data.x_test)}")
+    print(f"y_test shape = {np.shape(the_data.y_test)}")
+
+
+    #build a model
+    model = SafeDecisionTreeClassifier(random_state=1)
+    model.fit(x_train, y_train)
+    msg, disclosive = model.preliminary_check()
+
+    ##check direct method
+    print('==========> first running attacks explicitly via run_attack()')
+    for attack_name in ['worst_case','attribute','lira']:
+        print(f'===> running {attack_name} attack directly')
+        fname=f"modelDOTrun_attack_output_{attack_name}"
+        metadata= model.run_attack(the_data,attack_name,fname)
+        print('metadata is:')
+        for key,val in metadata.items():
+            if  isinstance(val,dict):
+                print(f" {key}")
+                for key1,val2 in val.items():
+                    print(f'   {key1} : {val2}')
+            else:
+                print(f' {key} : {val}')
+
+    ## now via request_release()
+    print('===>now running attacks implicitly via request_release()')
+    model.request_release("test.sav",the_data)
diff --git a/examples/worst_case_attack_example.py b/examples/worst_case_attack_example.py
index ac182c09..95aa1f72 100644
--- a/examples/worst_case_attack_example.py
+++ b/examples/worst_case_attack_example.py
@@ -21,14 +21,13 @@
 
 """
 import os
-from types import SimpleNamespace
 
 import numpy as np
 from sklearn.model_selection import train_test_split
 from sklearn.svm import SVC
 from sklearn.datasets import load_breast_cancer
 
-from attacks import worst_case_attack # pylint: disable = import-error
+from attacks import worst_case_attack, dataset # pylint: disable = import-error
 
 # [Researcher] Access a dataset
 X, y = load_breast_cancer(return_X_y=True, as_frame=False)
@@ -49,25 +48,36 @@
 test_preds = target_model.predict_proba(test_X)
 
 # [TRE] Define some attack parameters
-args_dict = {
+args = worst_case_attack.WorstCaseAttackArgs(
     # How many attacks to run -- in each the attack model is trained on a different
     # subset of the data
-    'n_reps': 2,
+    n_reps=10,
+    # number of baseline (dummy) experiments to do
+    n_dummy_reps=1,
     # Threshold to determine significance of things
-    'p_thresh': 0.05,
+    p_thresh=0.05,
     # Filename arguments needed by the code, meaningless if run programmatically
-    'in_sample_filename': None,
-    'out_sample_filename': None,
+    in_sample_filename=None,
+    out_sample_filename=None,
     # Proportion of data to use as a test set for the attack model;
-    'test_prop': 0.5
-}
+    test_prop=0.5,
+    # Report name is None - don't make json or pdf files
+    report_name=None
+)
+
+# [TRE / Researcher] Wrap the data in a dataset object
+dataset_obj = dataset.Data()
+dataset_obj.add_processed_data(train_X, train_y, test_X, test_y)
 
-# Convert into a namespace so that the code can access attributes with dot ('.') syntax
-args = SimpleNamespace(**args_dict)
+# [TRE] Create the attack object
+attack_obj = worst_case_attack.WorstCaseAttack(args)
 
-# [TRE] Call attack code
-metrics, metadata = worst_case_attack.attack(args, train_preds, test_preds)
+# [TRE] Run the attack
+attack_obj.attack(dataset_obj, target_model)
 
+# [TRE] Grab the output
+output = attack_obj.make_report()
+metadata = output['metadata']
 # [TRE] explore the metrics
 # For how many of the reps is the AUC p-value significant, with and without FDR correction. A
 # significant P-value means that the attack was statistically successful at predicting rows at
@@ -94,39 +104,29 @@
     f"{metadata['global_metrics']['n_sig_pdif_vals_corrected']}/{args.n_reps}"
 )
 
-# [TRE] to compare the results obtained with those expected by chance, TRE staff can
-# also run dummy attacks
-# [TRE] Generate some fake model predictions in which there is no difference between the
-# distribution of probabilities over the training and testing data. The number of rows to
-# generate are set to match the real example
-dummy_train, dummy_test = worst_case_attack.generate_arrays(
-    len(train_preds),
-    len(test_preds)
-)
-
-# [TRE] runs the attacks on the dummy predictions
-dummy_metrics, dummy_metadata = worst_case_attack.attack(args, dummy_train, dummy_test)
+# [TRE] to compare the results obtained with those expected by chance, the attack runs some
+# Baseline experiments too
 
 # [TRE] looks at the metric values to compare with those for the model
 print(
     "(dummy) Number of significant AUC values (raw):",
-    f"{dummy_metadata['global_metrics']['n_sig_auc_p_vals']}/{args.n_reps}"
+    f"{metadata['baseline_global_metrics']['n_sig_auc_p_vals']}/{args.n_reps}"
 )
 
 print(
     "(dummy) Number of significant AUC values (FDR corrected):",
-    f"{dummy_metadata['global_metrics']['n_sig_auc_p_vals_corrected']}/{args.n_reps}"
+    f"{metadata['baseline_global_metrics']['n_sig_auc_p_vals_corrected']}/{args.n_reps}"
 )
 
 # Or the number of repetitions in which the PDIF (0.1) was significant
 print(
     "(dummy) Number of significant PDIF values (proportion of 0.1), raw:",
-    f"{dummy_metadata['global_metrics']['n_sig_pdif_vals']}/{args.n_reps}"
+    f"{metadata['baseline_global_metrics']['n_sig_pdif_vals']}/{args.n_reps}"
 )
 
 print(
     "(dummy) Number of significant PDIF values (proportion of 0.1), FDR corrected:",
-    f"{dummy_metadata['global_metrics']['n_sig_pdif_vals_corrected']}/{args.n_reps}"
+    f"{metadata['baseline_global_metrics']['n_sig_pdif_vals_corrected']}/{args.n_reps}"
 )
 
 print("Programmatic example finished")
@@ -155,10 +155,11 @@
         "--out-of-sample-preds test_preds.csv "
         "--n-reps 10 "
         "--report-name example_report_risky "
-        "--dummy-reps 1 "
+        "--n-dummy-reps 1 "
         "--test-prop 0.1"
+        "--report-name example_report"
     )
 )
 
-# [TRE] The code produces a .pdf report (example_report_risky.pdf) and a .json file that can be
-# injesetd by the shiny app
+# [TRE] The code produces a .pdf report (example_report.pdf) and a .json file (example_report.json)
+# that can be injesetd by the shiny app
diff --git a/requirements.txt b/requirements.txt
index 3cc9abbf..55906140 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,7 @@
 dictdiffer
 fpdf
 joblib
+multiprocess
 numpy
 pandas
 pylint
diff --git a/safemodel/classifiers/safedecisiontreeclassifier.py b/safemodel/classifiers/safedecisiontreeclassifier.py
index 5139f08b..3dd67566 100644
--- a/safemodel/classifiers/safedecisiontreeclassifier.py
+++ b/safemodel/classifiers/safedecisiontreeclassifier.py
@@ -115,12 +115,23 @@ class SafeDecisionTreeClassifier(SafeModel, DecisionTreeClassifier):
     def __init__(self, **kwargs: Any) -> None:
         """Creates model and applies constraints to params."""
         SafeModel.__init__(self)
-        DecisionTreeClassifier.__init__(self, **kwargs)
+        self.basemodel_paramnames=[
+            'criterion','splitter','max_depth','min_samples_split',
+            'min_samples_leaf','min_weight_fraction_leaf','max_features',
+            'random_state','max_leaf_nodes','min_impurity_decrease',
+            'class_weight','ccp_alpha']
+
+        the_kwds=dict()
+        for key,val in kwargs.items():
+            if key in self.basemodel_paramnames:
+                the_kwds[key]=val
+        DecisionTreeClassifier.__init__(self, **the_kwds)
         self.model_type: str = "DecisionTreeClassifier"
         super().preliminary_check(apply_constraints=True, verbose=True)
-        self.ignore_items = ["model_save_file", "ignore_items"]
+        self.ignore_items = ["model_save_file","basemodel_paramnames", "ignore_items"]
         self.examine_seperately_items = ["tree_"]
 
+
     def additional_checks(
         self, curr_separate: dict, saved_separate: dict
     ) -> tuple[str, str]:
diff --git a/safemodel/classifiers/saferandomforestclassifier.py b/safemodel/classifiers/saferandomforestclassifier.py
index c66f7e89..014390c6 100644
--- a/safemodel/classifiers/saferandomforestclassifier.py
+++ b/safemodel/classifiers/saferandomforestclassifier.py
@@ -20,7 +20,18 @@ class SafeRandomForestClassifier(SafeModel, RandomForestClassifier):
     def __init__(self, **kwargs: Any) -> None:
         """Creates model and applies constraints to params"""
         SafeModel.__init__(self)
-        RandomForestClassifier.__init__(self, **kwargs)
+        self.basemodel_paramnames=[
+            'n_estimators','criterion','max_depth','min_samples_split',
+            'min_samples_leaf','min_weight_fraction_leaf','max_features',
+            'max_leaf_nodes','min_impurity_decrease','bootstrap',
+            'oob_score','n_jobs','random_state','verbose'
+            'warm_start','class_weight','ccp_alpha','max_samples']
+
+        the_kwds=dict()
+        for key,val in kwargs.items():
+            if key in self.basemodel_paramnames:
+                the_kwds[key]=val
+        RandomForestClassifier.__init__(self, **the_kwds)
         self.model_type: str = "RandomForestClassifier"
         super().preliminary_check(apply_constraints=True, verbose=True)
         self.ignore_items = [
diff --git a/safemodel/safemodel.py b/safemodel/safemodel.py
index da2b63fa..faa5c582 100644
--- a/safemodel/safemodel.py
+++ b/safemodel/safemodel.py
@@ -8,13 +8,19 @@
 import logging
 import pathlib
 import pickle
+import os
 from typing import Any
 import datetime
 import tensorflow as tf
 
+
 import joblib
 from dictdiffer import diff
 
+from attacks import attribute_attack, worst_case_attack, dataset,report
+from attacks.likelihood_attack import LIRAAttackArgs, LIRAAttack # pylint: disable = import-error
+
+
 logger = logging.getLogger(__file__)
 logger.setLevel(logging.DEBUG)
 
@@ -233,6 +239,7 @@ def __init__(self) -> None:
         self.model_save_file: str = "None"
         self.ignore_items: list[str] = []
         self.examine_seperately_items: list[str] = []
+        self.basemodel_paramnames=[]
         self.filename: str = "None"
         self.researcher: str = "None"
         try:
@@ -240,6 +247,22 @@ def __init__(self) -> None:
         except (ImportError, KeyError, OSError):
             self.researcher = "unknown"
 
+
+
+    def get_params(self,deep=True):
+        """gets dictionary of parameter values
+        restricted to those expected by base classifier.
+        """
+        the_params={}
+        for key,val in self.__dict__.items():
+            if key  in self.basemodel_paramnames:
+                the_params[key]=val
+        if deep:
+            pass #not implemented yet
+        return the_params
+
+
+
     def save(self, name: str = "undefined") -> None:
         """Writes model to file in appropriate format.
 
@@ -663,7 +686,7 @@ def additional_checks(
 
         return msg, disclosive
 
-    def request_release(self, filename: str = "undefined") -> None:
+    def request_release(self, filename: str = "undefined",data_obj:dataset.Data=None) -> None:
         """Saves model to filename specified and creates a report for the TRE
         output checkers.
 
@@ -673,11 +696,23 @@ def request_release(self, filename: str = "undefined") -> None:
         filename: string
         The filename used to save the model
 
+        dataobj: object of type Data
+        Contains train/test data and encoding dictionary needed to run attacks
+
         Returns
         -------
 
+
         Notes
         -----
+         1. The dataset object is saved in a file called filebase_data.json
+         (where filebase= filename without the extension)
+         for reference/use by the TRE.
+         Data should never be held or stored with the model.
+         Clearly filebase_data.json mst never leave the TRE.
+         2. If data_obj is not null, then worst case MIA and attribute inference
+         attacks are called via run_attack.
+         Outputs from the attacks will be stored in filebase_attack_res.json
 
 
 
@@ -706,13 +741,108 @@ def request_release(self, filename: str = "undefined") -> None:
                 output["recommendation"] = "Do not allow release"
                 output["reason"] = msg_prel + msg_post
 
+
+            ##Run attacks programmatically if possible
+            if data_obj is not None:
+                #make filenames and save a copy of the data
+                with open(os.path.splitext(filename)[0] +"_data.pickle", 'wb') as handle:
+                    pickle.dump(data_obj, handle, protocol=pickle.HIGHEST_PROTOCOL)
+
+                for attack_name in ['worst_case','lira','attribute']:
+                    output[f"{attack_name}_results"]=  self.run_attack(data_obj,
+                                      attack_name,
+                                      f"{os.path.splitext(filename)[0]}_{attack_name}_res"
+                                      )
+
+
+
             now = datetime.datetime.now()
             output["timestamp"] = str(now.strftime("%Y-%m-%d %H:%M:%S"))
-            json_str = json.dumps(output, indent=4)
+
+            json_str = json.dumps(output, indent=4,cls=report.NumpyArrayEncoder)
             outputfilename = self.researcher + "_checkfile.json"
             with open(outputfilename, "a", encoding="utf-8") as file:
                 file.write(json_str)
 
+
+    def run_attack(self,
+                   data_obj:dataset.Data=None,
+                   attack_name:str="worst_case",
+                   filename:str="undefined")->dict:
+
+        """Runs a specified attack on the trained model and saves a report to file
+
+        Parameters
+        ----------
+        data_obj: Data
+        the dataset in the form of a Data object
+
+        attack_name: string
+
+        filebasename: string
+        Report will be saved to filebasename.json
+
+
+        Returns
+        -------
+        dict of meta data results
+
+        Notes
+        -----
+        Currently implement attack types are:
+        Likelihood Ratio: lira
+        Worst_Case Membership inference: worst_case
+        Single Attribute Inference: attributes
+        """
+        if attack_name == "worst_case":
+            attack_args = worst_case_attack.WorstCaseAttackArgs(n_reps=10,
+                # number of baseline (dummy) experiments to do
+                n_dummy_reps=1,
+                # Threshold to determine significance of things
+                p_thresh=0.05,
+                # Filename arguments needed by the code, meaningless if run programmatically
+                in_sample_filename=None,
+                out_sample_filename=None,
+                # Proportion of data to use as a test set for the attack model;
+                test_prop=0.5,
+                # Report name is None - don't make json or pdf files
+                report_name=None
+            )
+            attack_obj = worst_case_attack.WorstCaseAttack(attack_args)
+            attack_obj.attack(dataset=data_obj,target_model=self)
+            output = attack_obj.make_report()
+            metadata = output['metadata']
+
+        elif attack_name=="lira":
+            args = LIRAAttackArgs(n_shadow_models=100, report_name="lira_example_report")
+            attack_obj = LIRAAttack(args)
+            attack_obj.attack(data_obj,self)
+            output = attack_obj.make_report() # also makes .pdf and .json files
+            metadata = output['metadata']
+
+        elif attack_name == "attribute":
+            attack_args = attribute_attack.AttributeAttackArgs(report_name="aia_example")
+            attack_obj = attribute_attack.AttributeAttack(attack_args)
+            attack_obj.attack(data_obj, self)
+            output = attack_obj.make_report()
+            metadata = output["metadata"]
+
+        else:
+            metadata= {}
+            metadata["outcome"] = "unrecognised attack type requested"
+
+        print(f'attack {attack_name}, metadata {metadata}')
+
+
+        try:
+            with open(f'{filename}.json', 'w',encoding='utf-8') as fp:
+                json.dump(metadata, fp,cls=report.NumpyArrayEncoder)
+        except TypeError:
+            print(f'couldnt serialise metadata {metadata} for attack {attack_name}')
+
+        return metadata
+
+
     def __str__(self) -> str:
         """Returns string with model description."""
         return self.model_type + " with parameters: " + str(self.__dict__)


Test patch:
diff --git a/.github/workflows/lint-test-on-push.yml b/.github/workflows/lint-test-on-push.yml
index adc6a2f9..a3cbe2ed 100644
--- a/.github/workflows/lint-test-on-push.yml
+++ b/.github/workflows/lint-test-on-push.yml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ["3.8"]
+        python-version: ["3.10"]
 
     steps:
       - uses: actions/checkout@v3