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 |