DBotTrainTextClassifierV2

Train a machine learning text classifier.

python · Base

Source

# pylint: disable=no-member
import gc
from collections import Counter, defaultdict

import demisto_ml
import pandas as pd
from CommonServerPython import *
from sklearn.model_selection import StratifiedKFold

ALL_LABELS = "*"
GENERAL_SCORES = {
    "micro avg": "The metrics is applied globally by counting the total true positives, false negatives and false positives",
    "macro avg": "The metrics is applied for each label, and find their unweighted mean.",
    "weighted avg": "The metrics is applied for each label, and find their average weighted by support "
    "(the number of true instances for each label). This alters macro to account for label imbalance;",
}

DBOT_TAG_FIELD = "dbot_internal_tag_field"
MIN_INCIDENTS_THRESHOLD = 100
PREDICTIONS_OUT_FILE_NAME = "predictions_on_test_set.csv"

# FROM_SCRATCH_TRAINING_ALGO is the UI equivalent of FASTTEXT_TRAINING_ALGO
FROM_SCRATCH_TRAINING_ALGO = "from_scratch"
FINETUNE_TRAINING_ALGO = "fine_tune"
FASTTEXT_TRAINING_ALGO = "fasttext"
AUTO_TRAINING_ALGO = "auto"

# the following mapping need to correspond to predict_phishing_words func at DBotPredictPhishingWords
ALGO_TO_MODEL_TYPE = {FASTTEXT_TRAINING_ALGO: "fasttext_phishing", FINETUNE_TRAINING_ALGO: "torch_phishing"}
FINETUNE_LABELS = ["Malicious", "Non-Malicious"]


def get_phishing_map_labels(comma_values):
    if comma_values == ALL_LABELS:
        return comma_values
    values = [x.strip() for x in comma_values.split(",")]
    labels_dict = {}
    for v in values:
        v = v.strip()
        if ":" in v:
            splited = v.rsplit(":", maxsplit=1)
            labels_dict[splited[0].strip()] = splited[1].strip()
        else:
            labels_dict[v] = v
    if len(set(labels_dict.values())) == 1:
        mapped_value = list(labels_dict.values())[0]
        return_error(f"Label mapping error: you need to map to at least two labels: {mapped_value}.")
    return {k.encode("utf-8", "ignore").decode("utf-8"): v for k, v in labels_dict.items()}


def read_file(input_data, input_type):
    data = []  # type: List[Dict[str, str]]
    file_path, file_content = "", ""
    if not input_data:
        return data
    if input_type.endswith("string"):
        if "b64" in input_type:
            input_data = base64.b64decode(input_data)
            file_content = input_data.decode("utf-8")
        else:
            file_content = input_data
    else:
        res = demisto.getFilePath(input_data)
        if not res:
            return_error(f"Entry {input_data} not found")
        file_path = res["path"]
        if input_type.startswith("json"):
            with open(file_path) as f:
                file_content = f.read()
    if input_type.startswith("csv"):
        return pd.read_csv(file_path).fillna("").to_dict(orient="records")
    elif input_type.startswith("json"):
        return json.loads(file_content)
    elif input_type.startswith("pickle"):
        return pd.read_pickle(file_path, compression=None)
    else:
        return_error(f"Unsupported file type {input_type}")
        return None


def get_file_entry_id(file_name):
    file_name = file_name.strip()
    res = demisto.dt(demisto.context(), f"File(val.Name == '{file_name}')")
    if not res or len(res) == 0:
        return_error(f"Cannot find file entry id in context by filename: {file_name}")
    if isinstance(res, list):
        res = res[0]
    return res["EntryID"]


def read_files_by_name(file_names, input_type):
    names = filter(None, argToList(file_names))  # type: ignore[var-annotated]
    data = []
    for name in names:
        data += read_file(get_file_entry_id(name), input_type)
    return data


def get_data_with_mapped_label(data, labels_mapping, tag_field):
    new_data = []
    exist_labels_counter = defaultdict(int)  # type: Dict[str, int]
    missing_labels_counter = defaultdict(int)  # type: Dict[str, int]
    for row in data:
        original_label = row[tag_field]
        if labels_mapping == ALL_LABELS:
            row[tag_field] = original_label
        else:
            if original_label in labels_mapping:
                row[tag_field] = labels_mapping[original_label]
            elif original_label.lower() in labels_mapping:
                original_label = original_label.lower()
                row[tag_field] = labels_mapping[original_label]
            else:
                missing_labels_counter[original_label] += 1
                continue
        exist_labels_counter[original_label] += 1
        new_data.append(row)

    return new_data, dict(exist_labels_counter), dict(missing_labels_counter)


def store_model_in_demisto(
    model_name,
    model_override,
    X,
    y,
    confusion_matrix,
    threshold,
    y_test_true,
    y_test_pred,
    y_test_pred_prob,
    target_accuracy,
    algorithm,
):
    global ALGO_TO_MODEL_TYPE
    phishing_model = demisto_ml.train_model_handler(X, y, algorithm=algorithm, compress=True)
    model_labels = phishing_model.get_model_labels()
    model_data = phishing_model.dumps()
    res = demisto.executeCommand(
        "createMLModel",
        {
            "modelData": model_data,
            "modelName": model_name,
            "modelLabels": model_labels,
            "modelOverride": model_override,
            "modelExtraInfo": {"threshold": threshold},
            "modelType": ALGO_TO_MODEL_TYPE[algorithm],
        },
    )
    if is_error(res):
        return_error(get_error(res))

    y_test_pred_prob = [float(x) for x in y_test_pred_prob]
    res = demisto.executeCommand(
        "evaluateMLModel",
        {
            "modelConfusionMatrix": confusion_matrix,
            "modelName": model_name,
            "modelEvaluationVectors": {"Ypred": y_test_pred, "Ytrue": y_test_true, "YpredProb": y_test_pred_prob},
            "modelConfidenceThreshold": threshold,
            "modelTargetPrecision": target_accuracy,
        },
    )
    if is_error(res):
        return_error(get_error(res))


def find_keywords(data, tag_field, text_field, min_score):
    keywords = demisto_ml.get_keywords_for_labels(data, tag_field, text_field)
    human_readable = "# Keywords per category\n"
    for category, scores in keywords.items():
        sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        table_items = [{"Word": word, "Score": f"{score:.2f}"} for word, score in sorted_scores if score >= min_score]
        human_readable += tableToMarkdown(category, table_items, ["Word", "Score"])
    demisto.results(
        {
            "Type": entryTypes["note"],
            "Contents": keywords,
            "ContentsFormat": formats["json"],
            "HumanReadable": human_readable,
            "HumanReadableFormat": formats["markdown"],
        }
    )


def set_tag_field(data, tag_fields):
    empty_label_indices = []
    for i, d in enumerate(data):
        found_field = False
        for field in tag_fields:
            if d.get(field) is not None:
                label = d[field]
                if isinstance(label, list) and len(label) > 0:
                    label = label[0]
                elif isinstance(label, list) and len(label) == 0:
                    continue
                label = label.encode("utf-8", "ignore").decode("utf-8")
                d[DBOT_TAG_FIELD] = str(label)
                found_field = True
                break
        if not found_field:
            empty_label_indices.append(i)
    data = [d for i, d in enumerate(data) if i not in empty_label_indices]
    return data


def output_model_evaluation(model_name, y_test, y_pred, res, context_field, human_readable_title=None):
    threshold = float(res["Contents"]["threshold"])
    confusion_matrix_at_thresh = json.loads(res["Contents"]["csr_matrix_at_threshold"])
    confusion_matrix_no_thresh = json.loads(res["Contents"]["csr_matrix_no_threshold"])
    metrics_df = json.loads(res["Contents"]["metrics_df"])
    human_readable = res["HumanReadable"]
    if human_readable_title is not None:
        human_readable = "\n".join([human_readable_title, human_readable])
    result_entry = {
        "Type": entryTypes["note"],
        "Contents": {
            "Threshold": threshold,
            "ConfusionMatrixAtThreshold": confusion_matrix_at_thresh,
            "ConfusionMatrixNoThreshold": confusion_matrix_no_thresh,
            "Metrics": metrics_df,
            "YTrue": y_test,
            "YPred": y_pred,
        },
        "ContentsFormat": formats["json"],
        "HumanReadable": human_readable,
        "HumanReadableFormat": formats["markdown"],
        "EntryContext": {
            context_field: {
                "ModelName": model_name,
                "EvaluationScores": metrics_df,
                "ConfusionMatrix": confusion_matrix_at_thresh,
                "ConfusionMatrixNoThresh": confusion_matrix_no_thresh,
            }
        },
    }
    demisto.results(result_entry)
    confusion_matrix_at_thresh = {k: v for k, v in confusion_matrix_at_thresh.items() if k != "All"}
    confusion_matrix_at_thresh = {
        k: {sub_k: sub_v for sub_k, sub_v in v.items() if sub_k != "All"} for k, v in confusion_matrix_at_thresh.items()
    }
    return confusion_matrix_at_thresh, metrics_df


def get_ml_model_evaluation(y_test, y_pred, target_accuracy, target_recall, detailed=False):
    res = demisto.executeCommand(
        "GetMLModelEvaluation",
        {
            "yTrue": json.dumps(y_test),
            "yPred": json.dumps(y_pred),
            "targetPrecision": str(target_accuracy),
            "targetRecall": str(target_recall),
            "detailedOutput": "true" if detailed else "false",
        },
    )
    if is_error(res):
        return_error(get_error(res))
    return res[0]


def validate_data_and_labels(data, exist_labels_counter, labels_mapping, missing_labels_counter):
    labels_counter = Counter([x[DBOT_TAG_FIELD] for x in data])
    labels_below_thresh = [label for label, count in labels_counter.items() if count < MIN_INCIDENTS_THRESHOLD]
    if len(labels_below_thresh) > 0:
        err = [f"Minimum number of incidents per label required for training is {MIN_INCIDENTS_THRESHOLD}."]
        err += [f"The following labels have less than {MIN_INCIDENTS_THRESHOLD} incidents: "]
        for x in labels_below_thresh:
            err += [f"- {x}: {labels_counter[x]!s}"]
        err += ["Make sure that enough incidents exist in the environment per each of these labels."]
        missing_labels = ", ".join(missing_labels_counter.keys())
        err += [f"The following labels were not mapped to any label in the labels mapping: {missing_labels}."]
        if labels_mapping != ALL_LABELS:
            err += ["The given mapped labels are: {}.".format(", ".join(labels_mapping.keys()))]
        return_error("\n".join(err))
    if len(exist_labels_counter) == 0:
        err = ["Did not found any incidents with labels of the labels mapping."]
        if len(missing_labels_counter) > 0:
            err += ["The following labels were found: {}".format(", ".join(k for k in missing_labels_counter))]
            err += ["Please include these labels at the mapping, or change the query to include your relevant labels"]
        return_error("\n".join(err))
    if len(missing_labels_counter) > 0:
        human_readable = tableToMarkdown("Skip labels - did not match any of specified labels", missing_labels_counter)
        entry = {
            "Type": entryTypes["note"],
            "Contents": missing_labels_counter,
            "ContentsFormat": formats["json"],
            "HumanReadable": human_readable,
            "HumanReadableFormat": formats["markdown"],
        }
        demisto.results(entry)
    if len(exist_labels_counter) > 0:
        exist_labels_counter_mapped = {}
        for label, count in exist_labels_counter.items():
            mapped_label = labels_mapping[label] if isinstance(labels_mapping, dict) else label
            if mapped_label != label:
                label = f"{label} -> {mapped_label}"
            exist_labels_counter_mapped[label] = count
        human_readable = tableToMarkdown("Found labels", exist_labels_counter_mapped)
        entry = {
            "Type": entryTypes["note"],
            "Contents": exist_labels_counter,
            "ContentsFormat": formats["json"],
            "HumanReadable": human_readable,
            "HumanReadableFormat": formats["markdown"],
        }
        demisto.results(entry)
    if len({x[DBOT_TAG_FIELD] for x in data}) == 1:
        single_label = [x[DBOT_TAG_FIELD] for x in data][0]
        if labels_mapping == ALL_LABELS:
            err = [f"All received incidents have the same label: {single_label}."]
        else:
            err = [f"All received incidents mapped to the same label: {single_label}."]
        err += ["At least 2 different labels are required to train a classifier."]
        if labels_mapping == ALL_LABELS:
            err += ["Please make sure that incidents of at least 2 labels exist in the environment."]
        else:
            err += ["The following labels were not mapped to any label in the labels mapping:"]
            err += [", ".join(list(missing_labels_counter))]
            not_found_mapped_label = [x for x in labels_mapping if x not in exist_labels_counter or exist_labels_counter[x] == 0]
            if len(not_found_mapped_label) > 0:
                miss = ", ".join(not_found_mapped_label)
                err += [f"Notice that the following mapped labels were not found among all incidents: {miss}."]
        return_error("\n".join(err))


def return_file_result_with_predictions_on_test_set(data, original_text_fields, test_index, text_field, y_test, y_pred_dict):
    if original_text_fields is None or original_text_fields.strip() == "":
        original_text_fields = [text_field]
    else:
        original_text_fields = re.split(r"[|,]", original_text_fields)
        original_text_fields = [x.strip() for x in original_text_fields] + [text_field]
    predictions_data = {}
    test_data = [data[i] for i in test_index]
    for field in original_text_fields:
        predictions_data[field] = [record.get(field, "") for record in test_data]
    predictions_data["y_true"] = y_test
    y_pred = []
    y_pred_prob = []
    for y_i in y_pred_dict:
        y_pred_prob_i = max(y_i.values())
        y_pred_i = [label for label, label_prob in y_i.items() if y_i[label] == y_pred_prob_i][0]
        y_pred.append(y_pred_i)
        y_pred_prob.append(y_pred_prob_i)
    predictions_data["y_pred"] = y_pred
    predictions_data["y_pred_prob"] = y_pred_prob
    df = pd.DataFrame(predictions_data)
    non_empty_columns = [field for field in original_text_fields if df[field].astype(bool).any()]
    csv_df = df.to_csv(columns=non_empty_columns + ["y_true", "y_pred", "y_pred_prob"], encoding="utf-8")
    demisto.results(fileResult(PREDICTIONS_OUT_FILE_NAME, csv_df))


def get_train_and_test_sets_indices(X, y):
    train_set_ratio = float(demisto.args()["trainSetRatio"])
    n_splits = int(1.0 / (1 - train_set_ratio))
    skf = StratifiedKFold(n_splits=n_splits, shuffle=False, random_state=None)
    skf.get_n_splits(X, y)
    train_index, test_index = list(skf.split(X, y))[-1]
    return test_index, train_index


def get_X_and_y_from_data(data, text_field):
    y = [x[DBOT_TAG_FIELD] for x in data]
    X = [x[text_field] for x in data]
    if len(X) != len(y):
        return_error("Error: data and tag data are different length")
    return X, y


def validate_labels_and_decide_algorithm(y, algorithm):
    labels_counter = Counter(y)  # type: Dict[str, int]
    illegal_labels_for_fine_tune = [label for label in labels_counter if label not in FINETUNE_LABELS]
    if algorithm == FINETUNE_TRAINING_ALGO and len(illegal_labels_for_fine_tune) > 0:
        error = [
            "When trainingAlgorithm is set to {}, all labels must be mapped to {}.\n".format(
                algorithm, ", ".join(FINETUNE_LABELS)
            )
        ]
        error += ["The following labels/verdicts need to be mapped to one of those values: "]
        error += [", ".join(illegal_labels_for_fine_tune) + "."]
        return_error("\n".join(error))
        return None
    elif algorithm == AUTO_TRAINING_ALGO:
        return FASTTEXT_TRAINING_ALGO
    else:
        return algorithm


def validate_confusion_matrix(confusion_matrix):
    for label in confusion_matrix:
        tp = confusion_matrix[label][label]
        fp = sum(confusion_matrix[label_other][label] for label_other in confusion_matrix if label != label_other)
        if tp == fp == 0:
            return False
    return True


def main():
    input = demisto.args().get("input")
    input_type = demisto.args().get("inputType", "pickle_filename")
    model_name = demisto.args().get("modelName", "phishing_model")
    store_model = demisto.args().get("storeModel") == "true"
    model_override = demisto.args().get("overrideExistingModel", "false") == "true"
    target_accuracy = float(demisto.args().get("targetAccuracy", "0.8"))
    text_field = demisto.args().get("textField", "dbot_processed_text")
    tag_fields = demisto.args().get("tagField").split(",")
    labels_mapping = get_phishing_map_labels(demisto.args().get("phishingLabels"))
    keyword_min_score = float(demisto.args().get("keywordMinScore", "0.05"))
    return_predictions_on_test_set = demisto.args().get("returnPredictionsOnTestSet", "false") == "true"
    original_text_fields = demisto.args().get("originalTextFields", "emailsubject|name,emailbody|emailbodyhtml")
    algorithm = demisto.args().get("trainingAlgorithm", AUTO_TRAINING_ALGO)
    # FASTTEXT_TRAINING_ALGO and FROM_SCRATCH_TRAINING_ALGO are equivalent, replacement is done because ml_lib
    # expects algorithm as one of (FASTTEXT_TRAINING_ALGO, FINETUNE_TRAINING_ALGO)
    algorithm = FASTTEXT_TRAINING_ALGO if algorithm == FROM_SCRATCH_TRAINING_ALGO else algorithm

    if input_type.endswith("filename"):
        data = read_files_by_name(input, input_type.split("_")[0].strip())
    else:
        data = read_file(input, input_type)

    if len(data) < MIN_INCIDENTS_THRESHOLD:
        return_results(
            f"{len(data)} incident(s) received."
            f"\nMinimum number of incidents per label required for training: {MIN_INCIDENTS_THRESHOLD}."
            "\nMake sure that all arguments are set correctly and that enough incidents exist in the environment."
        )
    else:
        data = set_tag_field(data, tag_fields)
        data, exist_labels_counter, missing_labels_counter = get_data_with_mapped_label(data, labels_mapping, DBOT_TAG_FIELD)
        validate_data_and_labels(data, exist_labels_counter, labels_mapping, missing_labels_counter)
        # print important words for each category
        find_keywords_bool = "findKeywords" in demisto.args() and demisto.args()["findKeywords"] == "true"
        if find_keywords_bool:
            try:
                find_keywords(data, DBOT_TAG_FIELD, text_field, keyword_min_score)
            except Exception:
                pass
        X, y = get_X_and_y_from_data(data, text_field)
        algorithm = validate_labels_and_decide_algorithm(y, algorithm)
        test_index, train_index = get_train_and_test_sets_indices(X, y)
        X_train, X_test = [X[i] for i in train_index], [X[i] for i in test_index]
        y_train, y_test = [y[i] for i in train_index], [y[i] for i in test_index]
        phishing_model = demisto_ml.train_model_handler(X_train, y_train, algorithm=algorithm, compress=False)
        ft_test_predictions = phishing_model.predict(X_test)
        y_pred = [{y_tuple[0]: float(y_tuple[1])} for y_tuple in ft_test_predictions]
        if return_predictions_on_test_set:
            return_file_result_with_predictions_on_test_set(data, original_text_fields, test_index, text_field, y_test, y_pred)
        target_recall = 1 - float(demisto.args().get("maxBelowThreshold", 1))
        threshold_metrics_entry = get_ml_model_evaluation(y_test, y_pred, target_accuracy, target_recall, detailed=True)
        # show results for the threshold found - last result so it will appear first
        confusion_matrix, metrics_json = output_model_evaluation(
            model_name=model_name,
            y_test=y_test,
            y_pred=y_pred,
            res=threshold_metrics_entry,
            context_field="DBotPhishingClassifier",
        )
        actual_min_accuracy = min(v for k, v in metrics_json["Precision"].items() if k != "All")
        if store_model:
            del phishing_model
            gc.collect()
            if not validate_confusion_matrix(confusion_matrix):
                return_error(
                    "The trained model didn't manage to predict some of the classes. This model won't be stored."
                    "Please try to retrain the model using a different configuration."
                )
            y_test_pred = [y_tuple[0] for y_tuple in ft_test_predictions]
            y_test_pred_prob = [y_tuple[1] for y_tuple in ft_test_predictions]
            threshold = float(threshold_metrics_entry["Contents"]["threshold"])
            store_model_in_demisto(
                model_name=model_name,
                model_override=model_override,
                X=X,
                y=y,
                confusion_matrix=confusion_matrix,
                threshold=threshold,
                y_test_true=y_test,
                y_test_pred=y_test_pred,
                y_test_pred_prob=y_test_pred_prob,
                target_accuracy=actual_min_accuracy,
                algorithm=algorithm,
            )
            demisto.results(f"Done training on {len(y)} samples model stored successfully")
        else:
            demisto.results("Skip storing model")


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

README

Train a machine learning text classifier.

Script Data


Name Description
Script Type python3
Tags ml
Cortex XSOAR Version 5.0.0

Used In


This script is used in the following playbooks and scripts.

  • DBot Create Phishing Classifier V2
  • DBot Create Phishing Classifier V2 From File

Inputs


Argument Name Description
input The input file entry ID or JSON string.
modelName The model name to store in the system.
storeModel Whether to store the model in the system.
overrideExistingModel Whether to override the existing model if a model with the same name exists. Default is “false”.
targetAccuracy The target accuracy, between 0 and 1. Default is 0.8.
maxBelowThreshold Maximum nubmer of samples below the threshold (for the target accuracy).
tagField The field name with the label. Supports a comma-separated list, the first non-empty value will be taken.
textField The field name with the text to train.
phishingLabels A comma-separated list of email tags values and mapping. The script considers only the tags specified in this field. You can map label to another value by using this format: LABEL:MAPPED_LABEL. For example, for 4 values in email tag: malicious, credentials harvesting, inner communitcation, external legit email, unclassified. While training, we want to ignore “unclassified” tag, and refer to “credentials harvesting” as “malicious” too. Also, we want to merge “inner communitcation” and “external legit email” to one tag called “non-malicious”. The input will be: malicious, credentials harvesting:malicious, inner communitcation:non-malicious, external legit email:non-malicious
trainSetRatio The ratio of the training set to the entire data set, which is used for model evaluation.
inputType The input type.
keywordMinScore Minimum score for a word to be considered as a keyword between 0 and 1.
metric The metric to use for evaluating the model.
findKeywords Whether to extract keywords for the model. Can be “true” or “false”. Default is “true”.
returnPredictionsOnTestSet Whether to return a file that contains the model’s predictions on the test set. Can be “true” or “false”. Default is “false”.
originalTextFields A comma-separated list of incident fields names with the unprocessed text.
You can also use “|” if you want to choose the first non-empty value from a list of fields.
preProcessType Text pre-processing type. The default is “json”.
trainingAlgorithm The training algorithm to use for training the model. Default is “auto”. If “auto” is selected, the training algorithm will be chosen automatically based on the number of incidents per each label. Use “from_scratch” to train a new model from scratch, based on your incidents only. In general, “from_scratch” will perform better where the number of incidents is high (500 incidents or more per each verdict). “fine-tune” trains a model based on the out-of-the-box model. “fine-tune” will perform better when the number of incidents is relatively low. It’s possible to train multiple models using different algorithms options, and compare their results.

Outputs


Path Description Type
DBotPhishingClassifier.ModelName The model name. String
DBotPhishingClassifier.EvaluationScores The model evaluation scores (precision, coverage, etc.) for the found threshold. Unknown
DBotPhishingClassifier.ConfusionMatrix The model evaluation confusion matrix for the found threshold. Unknown
DBotPhishingClassifierNoThresh.EvaluationScores The model evaluation scores (precision, coverage, etc.) for threshold = 0. Unknown
DBotPhishingClassifierNoThresh.ConfusionMatrix The model evaluation confusion matrix for threshold = 0. Unknown