GetMLModelEvaluation

Finds a threshold for ML model, and performs an evaluation based on it.

python · Base

Source

import numpy as np
import pandas as pd
from CommonServerPython import *
from sklearn.metrics import precision_recall_curve, precision_score, recall_score
from tabulate import tabulate

# pylint: disable=no-member

METRICS = {}
METRICS["Precision"] = (
    "The precision of the class in the evaluation set that were classified as this class by the "
    "model. Precision is calculated by dividing the TPs of the class by the number of incidents that "
    "the model predicted as this class."
)
METRICS["TP (true positive)"] = "The number of incidents from the class in the evaluation set that were predicted correctly. "
METRICS["FP (false positive)"] = "The number of incidents from other classes that were predicted incorrectly as this class."
METRICS["Coverage"] = (
    "The number of incidents from the class in the evaluation set for which the confidence level of "
    "the model exceeded the threshold in the prediction."
)
METRICS["Total"] = "The total number of incidents from the class in the evaluation set."


def bold_hr(s):
    return f"**{s}:**"


def binarize(arr, threshold):
    return np.where(arr >= threshold, 1.0, 0)


def calculate_confusion_matrix(y_true, y_pred, y_pred_per_class, threshold):
    indices_higher_than_threshold = set()
    for i, y in enumerate(y_pred):
        if y_pred_per_class[y][i] >= threshold:
            indices_higher_than_threshold.add(i)

    y_true_at_threshold = [y for i, y in enumerate(y_true) if i in indices_higher_than_threshold]
    y_pred_at_threshold = [y for i, y in enumerate(y_pred) if i in indices_higher_than_threshold]
    test_tag = pd.Series(y_true_at_threshold)
    ft_test_predictions_labels = pd.Series(y_pred_at_threshold)
    csr_matrix = pd.crosstab(test_tag, ft_test_predictions_labels, rownames=["True"], colnames=["Predicted"], margins=True)
    return csr_matrix


def generate_metrics_df(y_true, y_true_per_class, y_pred, y_pred_per_class, threshold):
    df = pd.DataFrame(columns=["Class", "Precision", "Recall", "TP", "FP", "Coverage", "Total"])
    for class_ in sorted(y_pred_per_class):
        row = calculate_df_row(class_, threshold, y_true_per_class, y_pred_per_class)
        df = pd.concat([df, pd.DataFrame([row])], ignore_index=True)
    df = pd.concat(
        [
            df,
            pd.DataFrame(
                [
                    {
                        "Class": "All",
                        "Precision": df["Precision"].mean(),
                        "Recall": df["Recall"].mean(),
                        "TP": df["TP"].sum(),
                        "FP": df["FP"].sum(),
                        "Coverage": df["Coverage"].sum(),
                        "Total": df["Total"].sum(),
                    }
                ]
            ),
        ],
        ignore_index=True,
    )
    df = df[["Class", "Precision", "TP", "FP", "Coverage", "Total"]]
    explained_metrics = ["Precision", "TP (true positive)", "FP (false positive)", "Coverage", "Total"]
    explanation = [f"{bold_hr(metric)} {METRICS[metric]}" for metric in explained_metrics]
    df = df.set_index("Class")
    return df, explanation


def calculate_df_row(class_, threshold, y_true_per_class, y_pred_per_class):
    y_pred_class = y_pred_per_class[class_]
    y_true_class = y_true_per_class[class_]
    y_pred_class_binary = binarize(y_pred_class, threshold)
    precision = precision_score(y_true=y_true_class, y_pred=y_pred_class_binary)
    recall = recall_score(y_true=y_true_class, y_pred=y_pred_class_binary)
    classified_correctly = sum(
        1 for y_true_i, y_pred_i in zip(y_true_class, y_pred_class_binary) if y_true_i == 1 and y_pred_i == 1
    )
    above_thresh = sum(
        1
        for i, y_true_i in enumerate(y_true_class)
        if y_true_i == 1 and any(y_pred_per_class[c][i] >= threshold for c in y_pred_per_class)
    )
    fp = sum(1 for i, y_true_i in enumerate(y_true_class) if y_true_i == 0 and y_pred_class_binary[i] == 1.0)
    total = int(sum(y_true_class))
    row = {
        "Class": class_,
        "Precision": precision,
        "Recall": recall,
        "TP": classified_correctly,
        "FP": fp,
        "Coverage": int(above_thresh),
        "Total": total,
    }
    return row


def reformat_df_fractions_to_percentage(metrics_df):
    hr_df = metrics_df.copy()
    hr_df["Precision"] = hr_df["Precision"].apply(lambda p: f"{p * 100:.1f}%")
    hr_df["TP"] = hr_df.apply(
        lambda row: "{}/{} ({:.1f}%)".format(int(row["TP"]), int(row["Coverage"]), float(row["TP"]) * 100 / row["Coverage"]),
        axis=1,
    )
    hr_df["Coverage"] = hr_df.apply(
        lambda row: "{}/{} ({:.1f}%)".format(int(row["Coverage"]), row["Total"], float(row["Coverage"]) * 100 / row["Total"]),
        axis=1,
    )
    return hr_df


def output_report(
    y_true,
    y_true_per_class,
    y_pred,
    y_pred_per_class,
    found_threshold,
    target_precision,
    actual_threshold_precision,
    detailed_output=True,
):
    csr_matrix_at_threshold = calculate_confusion_matrix(y_true, y_pred, y_pred_per_class, found_threshold)
    csr_matrix_no_threshold = calculate_confusion_matrix(y_true, y_pred, y_pred_per_class, 0)

    metrics_df, metrics_explanation = generate_metrics_df(y_true, y_true_per_class, y_pred, y_pred_per_class, found_threshold)

    coverage = metrics_df.loc[["All"]]["Coverage"][0]
    test_set_size = metrics_df.loc[["All"]]["Total"][0]
    human_readable_threshold = ["## Summary"]
    # in case the found threshold meets the target accuracy
    if actual_threshold_precision >= target_precision or abs(found_threshold - target_precision) < 10**-2:
        human_readable_threshold += [
            f"- A confidence threshold of {found_threshold:.2f} meets the conditions of required precision."
        ]
    else:
        human_readable_threshold += [
            "- Could not find a threshold which meets the conditions of required precision. "
            f"The confidence threshold of {found_threshold:.2f} achieved highest "
            "possible precision"
        ]
    human_readable_threshold += [
        f"- {int(coverage)}/{int(test_set_size)} incidents of the evaluation set were predicted with higher confidence"
        f" than this threshold.",
        f"- The remainder, {int(test_set_size - coverage)}/{int(test_set_size)} incidents of the evaluation set, were"
        f" predicted with lower confidence than this threshold "
        "(these predictions were ignored).",
        f"- Expected coverage ratio: The model will attempt to provide a prediction for"
        f" {float(coverage) / test_set_size * 100:.2f}% of incidents. "
        f"({int(coverage)}/{int(test_set_size)})",
        "- Evaluation of the model performance using this probability threshold can be found below:",
    ]
    pd.set_option("display.max_columns", None)

    tablualted_csr = tabulate(reformat_df_fractions_to_percentage(metrics_df), tablefmt="pipe", headers="keys")
    class_metrics_human_readable = ["## Metrics per Class", tablualted_csr]
    class_metrics_explanation_human_readable = ["### Metrics Explanation"] + ["- " + row for row in metrics_explanation]
    csr_matrix_readable = [
        "## Confusion Matrix",
        "This table displays the predictions of the model on the evaluation set per each " + "class:",
        tabulate(csr_matrix_at_threshold, tablefmt="pipe", headers="keys").replace("True", "True \\ Predicted"),
        "\n",
    ]
    csr_matrix_no_thresh_readable = [
        "## Confusion Matrix - No Threshold",
        "This table displays the predictions of the model on the evaluation set per each " + "class when no threshold is used:",
        tabulate(csr_matrix_no_threshold, tablefmt="pipe", headers="keys").replace("True", "True \\ Predicted"),
        "\n",
    ]
    human_readable = []  # type: ignore
    if detailed_output:
        human_readable += human_readable_threshold + ["\n"]
    else:
        human_readable += [f"## Results for confidence threshold = {found_threshold:.2f}"] + ["\n"]
    human_readable += class_metrics_human_readable + ["\n"]
    human_readable += class_metrics_explanation_human_readable
    human_readable += csr_matrix_readable
    human_readable += csr_matrix_no_thresh_readable
    human_readable = "\n".join(human_readable)
    contents = {
        "threshold": found_threshold,
        "csr_matrix_at_threshold": csr_matrix_at_threshold.to_json(orient="index"),
        "csr_matrix_no_threshold": csr_matrix_no_threshold.to_json(orient="index"),
        "metrics_df": metrics_df.to_json(),
    }
    entry = {
        "Type": entryTypes["note"],
        "Contents": contents,
        "ContentsFormat": formats["json"],
        "HumanReadable": human_readable,
        "HumanReadableFormat": formats["markdown"],
        "EntryContext": {
            "GetMLModelEvaluation": {
                "Threshold": found_threshold,
                "ConfusionMatrixAtThreshold": csr_matrix_at_threshold.to_json(orient="index"),
                "ConfusionMatrixNoThreshold": csr_matrix_no_threshold.to_json(orient="index"),
                "Metrics": metrics_df.to_json(),
            }
        },
    }
    return entry


def merge_entries(entry, per_class_entry):
    entry = {
        "Type": entryTypes["note"],
        "Contents": entry["Contents"],
        "ContentsFormat": formats["json"],
        "HumanReadable": entry["HumanReadable"] + "\n" + per_class_entry["HumanReadable"],
        "HumanReadableFormat": formats["markdown"],
        "EntryContext": {**entry["EntryContext"], **per_class_entry["EntryContext"]},
    }
    return entry


def find_threshold(y_true, y_pred_all_classes, customer_target_precision, target_recall, detailed_output=True):
    labels = sorted(set(y_true + list(y_pred_all_classes[0].keys())))
    n_instances = len(y_true)
    y_true_per_class = {class_: np.zeros(n_instances) for class_ in labels}
    for i, y in enumerate(y_true):
        y_true_per_class[y][i] = 1.0
    y_pred_per_class = {class_: np.zeros(n_instances) for class_ in labels}
    y_pred = []
    for i, y in enumerate(y_pred_all_classes):
        predicted_class = sorted(y.items(), key=lambda x: x[1], reverse=True)[0][0]
        y_pred_per_class[predicted_class][i] = y[predicted_class]
        y_pred.append(predicted_class)

    class_to_arrs = {class_: {} for class_ in labels}  # type: Dict[str, Dict[str, Any]]
    for class_ in labels:
        precision_arr, recall_arr, thresholds_arr = precision_recall_curve(y_true_per_class[class_], y_pred_per_class[class_])
        class_to_arrs[class_]["precisions"] = precision_arr
        class_to_arrs[class_]["recalls"] = recall_arr
        class_to_arrs[class_]["thresholds"] = thresholds_arr

    # find threshold for all classes such as precision of all classes are higher than target precision:
    unified_threshold, unified_threshold_precision, target_unified_precision = find_best_threshold_for_target_precision(
        class_to_arrs, customer_target_precision, labels
    )
    if unified_threshold is None or unified_threshold_precision is None:
        error_message = f"Could not find any threshold at ranges {target_unified_precision} - {customer_target_precision:.2f}."
        return_error(error_message)
    entry = output_report(
        np.array(y_true),
        y_true_per_class,
        np.array(y_pred),
        y_pred_per_class,
        unified_threshold,
        customer_target_precision,
        unified_threshold_precision,
        detailed_output,
    )
    per_class_entry = calculate_per_class_report_entry(class_to_arrs, labels, y_pred_per_class, y_true_per_class)
    res = merge_entries(entry, per_class_entry)
    return res


def find_best_threshold_for_target_precision(class_to_arrs, customer_target_precision, labels):
    target_unified_precision = round(customer_target_precision, 2)
    unified_threshold_found = False
    threshold = None
    threshold_precision = None
    while not unified_threshold_found:
        threshold_per_class = {}
        precision_per_class = {}
        for class_ in labels:
            # indexing is done by purpose - the ith precision corresponds with threshold i-1. Last precision is 1
            for i, precision in enumerate(class_to_arrs[class_]["precisions"][:-1]):
                if class_to_arrs[class_]["thresholds"][i] == 0:
                    continue
                if precision > target_unified_precision:
                    threshold_per_class[class_] = class_to_arrs[class_]["thresholds"][i]
                    precision_per_class[class_] = precision
                    break
        if len(threshold_per_class) == len(labels):
            threshold_candidates = sorted(threshold_per_class.values())
            for threshold in threshold_candidates:
                legal_threshold_for_all_classes = True
                threshold_precision = sys.maxsize
                for class_ in labels:
                    i = np.argmax(class_to_arrs[class_]["thresholds"] >= threshold)  # type: ignore
                    threshold_precision_for_class = class_to_arrs[class_]["precisions"][i]
                    threshold_precision = min(threshold_precision, threshold_precision_for_class)  # type: ignore
                    if threshold_precision_for_class >= target_unified_precision:
                        legal_threshold_for_all_classes = True
                    else:
                        legal_threshold_for_all_classes = False
                        break
                if legal_threshold_for_all_classes:
                    unified_threshold_found = True
                    break
        elif target_unified_precision < 0:
            break
        target_unified_precision -= 0.01
    return threshold, threshold_precision, target_unified_precision


def calculate_per_class_report_entry(class_to_arrs, labels, y_pred_per_class, y_true_per_class):
    per_class_hr = ["## Per-Class Report"]
    per_class_hr += ["The following tables present evlauation of the model per class at different confidence thresholds:"]
    class_to_thresholds = {}
    for class_ in labels:
        class_to_thresholds[class_] = {0.001}  # using no threshold
        for target_precision in np.arange(0.95, 0.5, -0.05):
            # indexing is done by purpose - the ith precision corresponds with threshold i-1. Last precision is 1
            for i, precision in enumerate(class_to_arrs[class_]["precisions"][:-1]):
                if class_to_arrs[class_]["thresholds"][i] == 0:
                    continue
                if precision > target_precision and class_to_arrs[class_]["recalls"][i] > 0:
                    threshold = class_to_arrs[class_]["thresholds"][i]
                    class_to_thresholds[class_].add(threshold)
                    break
            if len(class_to_thresholds[class_]) >= 4:
                break
    per_class_context = {}
    for class_ in labels:
        class_threshold_df = pd.DataFrame(columns=["Threshold", "Precision", "Recall", "TP", "FP", "Coverage", "Total"])
        for threshold in sorted(class_to_thresholds[class_]):
            row = calculate_df_row(class_, threshold, y_true_per_class, y_pred_per_class)
            row["Threshold"] = threshold
            class_threshold_df = pd.concat([class_threshold_df, pd.DataFrame([row])], ignore_index=True)
        class_threshold_df = reformat_df_fractions_to_percentage(class_threshold_df)
        class_threshold_df["Threshold"] = class_threshold_df["Threshold"].apply(lambda p: f"{p:.2f}")
        class_threshold_df = class_threshold_df[["Threshold", "Precision", "TP", "FP", "Coverage", "Total"]]
        class_threshold_df = class_threshold_df.sort_values(by="Coverage", ascending=False)
        class_threshold_df = class_threshold_df.drop_duplicates(subset="Threshold", keep="first")
        class_threshold_df = class_threshold_df.drop_duplicates(subset="Precision", keep="first")
        class_threshold_df = class_threshold_df.set_index("Threshold")
        per_class_context[class_] = class_threshold_df.to_json()
        tabulated_class_df = tabulate(class_threshold_df, tablefmt="pipe", headers="keys")
        per_class_hr += [f"### {class_}", tabulated_class_df]
    per_class_entry = {
        "Type": entryTypes["note"],
        "ContentsFormat": formats["json"],
        "Contents": [],
        "HumanReadable": "\n".join(per_class_hr),
        "HumanReadableFormat": formats["markdown"],
        "EntryContext": {"GetMLModelEvaluation": {"PerClassReport": per_class_context}},
    }
    return per_class_entry


def convert_str_to_json(str_json, var_name):
    try:
        y_true = json.loads(str_json)
        return y_true
    except Exception as e:
        return_error(f"Exception while reading {var_name} :{e}")


def main():
    try:
        y_pred_all_classes = demisto.args()["yPred"]
        y_true = demisto.args()["yTrue"]
        target_precision = calculate_and_validate_float_parameter("targetPrecision")
        target_recall = calculate_and_validate_float_parameter("targetRecall")
        detailed_output = "detailedOutput" in demisto.args() and demisto.args()["detailedOutput"] == "true"
        y_true = convert_str_to_json(y_true, "yTrue")
        y_pred_all_classes = convert_str_to_json(y_pred_all_classes, "yPred")

        if not (y_true and y_pred_all_classes):
            raise DemistoException('Either "yPred" or "yTrue" are empty.')

        entries = find_threshold(
            y_true=y_true,
            y_pred_all_classes=y_pred_all_classes,
            customer_target_precision=target_precision,
            target_recall=target_recall,
            detailed_output=detailed_output,
        )

        demisto.results(entries)
    except Exception as e:
        return_error(f"Error in GetMLModelEvaluation:\n{e}")


def calculate_and_validate_float_parameter(var_name):
    try:
        res = float(demisto.args()[var_name]) if var_name in demisto.args() else 0
    except Exception:
        return_error(f"{var_name} must be a float between 0-1 or left empty")
    if res < 0 or res > 1:
        return_error(f"{var_name} must be a float between 0-1 or left empty")
    return res


if __name__ in ["__main__", "__builtin__", "builtins"]:
    main()

README

Finds a threshold for ML model, and performs an evaluation based on it

Script Data


Name Description
Script Type python3
Tags ml
Cortex XSOAR Version 5.0.0

Inputs


Argument Name Description
yTrue A list of labels of the test set
yPred A list of dictionaries contain probability predictions for all classes
targetPrecision minimum precision of all classes, ranges 0-1
targetRecall minimum recall of all classes, ranges 0-1
detailedOutput if set to ‘true’, the output will include a full explanation of the confidence threshold meaning

Outputs


Path Description Type
GetMLModelEvaluation.Threshold The found thresholds which meets the conditions of precision and recall String
GetMLModelEvaluation.ConfusionMatrixAtThreshold The model evaluation confusion matrix for mails above the threshold. Unknown
GetMLModelEvaluation.Metrics Metrics per each class (includes precision, true positive, coverage, etc.) Unknown