spaCy пустая модель NER не соответствует требованиям даже при обучении на большом наборе данных

Я пытаюсь создать собственную модель NER для идентификации объектов, связанных с кибербезопасностью (27 из них). Я решил использовать пустую модель, потому что думаю, что у меня достаточно большой (не уверен в этом) набор обучающих данных (~ 11 тысяч предложений, извлеченных из Википедии).

Для создания обучающих данных, необходимых для spaCy, я использовал утилиту PhraseMatcher. Идея состоит в том, чтобы сопоставить определенные предопределенные слова / фразы, относящиеся к объектам, которые я хочу идентифицировать, как показано ниже:

import spacy
from spacy.matcher import PhraseMatcher
nlp = spacy.load("en")

import pandas as pd
from tqdm import tqdm

from collections import defaultdict

Укажите метки сопоставления

users_pattern = [nlp(text) for text in ("user", "human", "person", "people", "end user")]
devices_pattern =  [nlp(text) for text in ("device", "peripheral", "appliance", "component", "accesory", "equipment", "machine")]
accounts_pattern = [nlp(text) for text in ("account", "user account", "username", "user name", "loginname", "login name", "screenname", "screen name", "account name")]
identifiers_pattern = [nlp(text) for text in ("attribute", "id", "ID", "code", "ID code")]
authentication_pattern = [nlp(text) for text in ("authentication", "authenticity", "certification", "verification", "attestation", "authenticator", "authenticators")]
time_pattern = [nlp(text) for text in ("time", "date", "moment", "present", "pace", "moment")]
unauthorized_pattern = [nlp(text) for text in ("unauthorized", "illegal", "illegitimate", "pirated", "unapproved", "unjustified", "unofficial")]
disclosure_pattern = [nlp(text) for text in ("disclosure", "acknowledgment", "admission", "exposure", "advertisement", "divulgation")]
network_pattern = [nlp(text) for text in ("network", "net", "networking", "internet", "Internet")]
wireless_pattern = [nlp(text) for text in ("wireless", "wifi", "Wi-Fi", "wireless networking")]
password_pattern = [nlp(text) for text in ("password", "passwords", "passcode", "passphrase")]
configuration_pattern = [nlp(text) for text in ("configuration", "composition")]
signatures_pattern = [nlp(text) for text in ("signature", "signatures", "digital signature", "electronic signature")]
certificates_pattern = [nlp(text) for text in ("certificate", "digital certificates", "authorization certificate", "public key certificates", "PKI", "X509", "X.509")]
revocation_pattern = [nlp(text) for text in ("revocation", "annulment", "cancellation")]
keys_pattern = [nlp(text) for text in ("key", "keys")]
algorithms_pattern = [nlp(text) for text in ("algorithm", "algorithms", "formula", "program")]
standard_pattern = [nlp(text) for text in ("standard", "standards", "specification", "specifications", "norm", "rule", "rules", "RFC")]
invalid_pattern = [nlp(text) for text in ("invalid", "false", "unreasonable", "inoperative")]
access_pattern = [nlp(text) for text in ("access", "connection", "entry", "entrance")]
blocking_pattern = [nlp(text) for text in ("blocking", "block", "blacklist", "blocklist", "close", "cut off", "deter", "prevent", "stop")]
notification_pattern = [nlp(text) for text in ("notification", "notifications", "notice", "warning")]
messages_pattern = [nlp(text) for text in ("message", "messages", "note", "news")]
untrusted_pattern = [nlp(text) for text in ("untrusted", "malicious", "unsafe")]
security_pattern = [nlp(text) for text in ("security", "secure", "securely", "protect", "defend", "guard")]
symmetric_pattern = [nlp(text) for text in ("symmetric", "symmetric crypto")]
asymmetric_pattern = [nlp(text) for text in ("asymmetric", "asymmetric crypto")]

matcher = PhraseMatcher(nlp.vocab)
matcher.add("USER", None, *users_pattern)
matcher.add("DEVICE", None, *devices_pattern)
matcher.add("ACCOUNT", None, *accounts_pattern)
matcher.add("IDENTIFIER", None, *identifiers_pattern)
matcher.add("AUTHENTICATION", None, *authentication_pattern)
matcher.add("TIME", None, *time_pattern)
matcher.add("UNAUTHORIZED", None, *unauthorized_pattern)
matcher.add("DISCLOSURE", None, *disclosure_pattern)
matcher.add("NETWORK", None, *network_pattern)
matcher.add("WIRELESS", None, *wireless_pattern)
matcher.add("PASSWORD", None, *password_pattern)
matcher.add("CONFIGURATION", None, *configuration_pattern)
matcher.add("SIGNATURE", None, *signatures_pattern)
matcher.add("CERTIFICATE", None, *certificates_pattern)
matcher.add("REVOCATION", None, *revocation_pattern)
matcher.add("KEY", None, *keys_pattern)
matcher.add("ALGORITHM", None, *algorithms_pattern)
matcher.add("STANDARD", None, *standard_pattern)
matcher.add("INVALID", None, *invalid_pattern)
matcher.add("ACCESS", None, *access_pattern)
matcher.add("BLOCKING", None, *blocking_pattern)
matcher.add("NOTIFICATION", None, *notification_pattern)
matcher.add("MESSAGE", None, *messages_pattern)
matcher.add("UNTRUSTED", None, *untrusted_pattern)
matcher.add("SECURITY", None, *security_pattern)
matcher.add("SYMMETRIC", None, *symmetric_pattern)
matcher.add("ASYMMETRIC", None, *asymmetric_pattern)

Подготовить обучающие данные

def offsetter(lbl, doc, matchitem):
    """
    Convert PhaseMatcher result to the format required in training (start, end, label)
    """
    o_one = len(str(doc[0:matchitem[1]]))
    subdoc = doc[matchitem[1]:matchitem[2]]
    o_two = o_one + len(str(subdoc))
    return (o_one, o_two, lbl)


to_train_ents = []
count_dic = defaultdict(int)

# Load the original sentences
df = pd.read_csv("sentences.csv", index_col=False)
phrases = df["sentence"].values

for line in tqdm(phrases):

    nlp_line = nlp(line)
    matches = matcher(nlp_line)
    
    if matches:
        
        for match in matches:

            match_id = match[0]
            start = match[1]
            end = match[2]

            label = nlp.vocab.strings[match_id]  # get the unicode ID, i.e. 'COLOR'
            span = nlp_line[start:end]  # get the matched slice of the doc

            count_dic[label] += 1

            res = [offsetter(label, nlp_line, match)]
            to_train_ents.append((line, dict(entities=res)))
           
count_dic = dict(count_dic)
        
TRAIN_DATA =  to_train_ents

После выполнения вышеуказанного кода я получил обучающие данные в формате, требуемом spaCy. Эти предложения содержат интересующие меня объекты, которые распределены, как показано ниже:

print(sorted(count_dic.items(), key=lambda x:x[1], reverse=True), len(count_dic))
sum(count_dic.values())


[('NETWORK', 1962), ('TIME', 1489), ('USER', 1206), ('SECURITY', 981), ('DEVICE', 884), ('STANDARD', 796), ('ACCESS', 652), ('ALGORITHM', 651), ('MESSAGE', 605), ('KEY', 423), ('IDENTIFIER', 389), ('BLOCKING', 354), ('AUTHENTICATION', 141), ('WIRELESS', 109), ('UNAUTHORIZED', 99), ('CONFIGURATION', 89), ('ACCOUNT', 86), ('UNTRUSTED', 77), ('PASSWORD', 62), ('DISCLOSURE', 58), ('NOTIFICATION', 55), ('INVALID', 44), ('SIGNATURE', 41), ('SYMMETRIC', 23), ('ASYMMETRIC', 11), ('CERTIFICATE', 10), ('REVOCATION', 9)] 27
11306

Затем я использовал стандартную процедуру обучения для обучения пустой модели NER в spaCy, показанной ниже.

Обучение пустой модели

# define variables
model = None  
n_iter = 100

if model is not None:
    nlp_new = spacy.load(model)  # load existing spaCy model
    print("Loaded model '%s'" % model)
else:
    nlp_new = spacy.blank("en")  # create blank Language class
    print("Created blank 'en' model")

# Add entity recognizer to model if it's not in the pipeline
# nlp.create_pipe works for built-ins that are registered with spaCy
if "ner" not in nlp_new.pipe_names:
    ner = nlp_new.create_pipe("ner")
    nlp_new.add_pipe(ner)
# otherwise, get it, so we can add labels to it
else:
    ner = nlp_new.get_pipe("ner")


# add labels
for _, annotations in TRAIN_DATA:
    for ent in annotations.get("entities"):
        ner.add_label(ent[2])
            
# get names of other pipes to disable them during training
other_pipes = [pipe for pipe in nlp_new.pipe_names if pipe != "ner"]

with nlp_new.disable_pipes(*other_pipes):  # only train NER
    
    if model is None:
        optimizer = nlp_new.begin_training()
    else:
        optimizer = nlp_new.resume_training()
    
    
    # Set this based on this resource: spacy compounding batch size
    sizes = compounding(1, 16, 1.001)
    
    # batch up the examples using spaCy's minibatch
    for itn in tqdm(range(n_iter)):
        losses = {}
        random.shuffle(TRAIN_DATA)
        batches = minibatch(TRAIN_DATA, size=sizes)
        for batch in batches:
            texts, annotations = zip(*batch)
            nlp_new.update(texts, annotations, sgd=optimizer, drop=0.2, losses=losses)
        print("Losses", losses)

Окончательный проигрыш после этого - около 500.

Наконец, я проверил, как работает новая модель, используя данные обучения. Я ожидал бы восстановить столько сущностей, сколько было изначально указано в наборе обучающих данных. Однако после выполнения приведенного ниже кода я получаю только около 600 экземпляров из ~ 11k в общей сложности.

Модель, обученная тестированию

count_dic = defaultdict(int)

for text, _ in TRAIN_DATA:
    
    doc = nlp_new(text)
    
    for ent in doc.ents:
        count_dic[ent.label_] += 1
        
print(sorted(count_dic.items(), key=lambda x:x[1], reverse=True), len(count_dic))
sum(count_dic.values())

[('TIME', 369), ('NETWORK', 47), ('IDENTIFIER', 41), ('BLOCKING', 28), ('USER', 22), ('STANDARD', 22), ('SECURITY', 15), ('MESSAGE', 15), ('ACCESS', 7), ('CONFIGURATION', 7), ('DEVICE', 7), ('KEY', 4), ('ALGORITHM', 3), ('SYMMETRIC', 2), ('UNAUTHORIZED', 2), ('SIGNATURE', 2), ('WIRELESS', 1), ('DISCLOSURE', 1), ('INVALID', 1), ('PASSWORD', 1), ('NOTIFICATION', 1)] 21
598

Интересно, почему с помощью этой процедуры получается модель с таким неподходящим поведением. Мне известны комментарии в этих сообщениях: Обучение NER с использованием Spacy и Пользовательский NER SPACY не возвращает никаких объектов, но они не решают мою проблему.

Надеюсь, вы сможете поделиться своим мнением о том, что я сделал и как я могу улучшить обнаружение сущностей в обучающем наборе. Я думал, что 11к предложений будет достаточно, если я не делаю что-то не так. Я использую Python 3.6.9 и spaCy 2.2.4.

Спасибо большое за вашу помощь.

Обновлять

Я решил обучить модель, включая как положительные, так и отрицательные образцы. Теперь обучающие данные содержат более 40 тысяч предложений. Однако это изменение не улучшает результат классификации в обучающем наборе. Есть другие предложения?

Набор обучающих данных

Полный набор данных обучения можно загрузить с здесь.


person Paul    schedule 08.06.2020    source источник
comment
Не могли бы вы привести несколько примеров входных данных?   -  person Raqib    schedule 23.06.2020
comment
Просто добавил входные данные @raqib   -  person Paul    schedule 23.06.2020
comment
Я просмотрел код. Прежде чем я углублюсь в это, у меня есть несколько вопросов к вам. Какова ваша цель обучения модели? У вас есть предопределенное количество фраз, то есть 27 категорий, которые вы ищете, или вы хотите обобщить термины кибербезопасности, которых вы раньше не видели? Если да, то как вы собираетесь их классифицировать?   -  person Raqib    schedule 26.06.2020
comment
Моя цель - обучить модель NER определять 27 предопределенных категорий кибербезопасности. Я не собираюсь обобщать термины, которых я раньше не видел. Спасибо @raqib   -  person Paul    schedule 27.06.2020


Ответы (2)


Я не думаю, что обучение модели spaCy - правильный выбор в вашем случае. Целью обучения модели spaCy было бы обобщение. В вашем случае вас интересуют только 27 предопределенных категорий, и, на мой взгляд, использование подхода, основанного на правилах, будет правильным выбором.

Я могу придумать два способа решения этой проблемы:

  1. Regex (не добавляет внешней зависимости использования и загрузки spaCy)
  2. Возможности spaCy сопоставления на основе правил (сопоставление токенов, сопоставление фраз или линейка сущностей)

Примечание.

Вы уже решили проблему с помощью PhraseMatcher, описанного выше.

import spacy
from spacy.matcher import PhraseMatcher

import pandas as pd


nlp = spacy.load("en")

users_pattern = [nlp(text) for text in ("user", "human", "person", "people", "end user")]
devices_pattern =  [nlp(text) for text in ("device", "peripheral", "appliance", "component", "accesory", "equipment", "machine")]
accounts_pattern = [nlp(text) for text in ("account", "user account", "username", "user name", "loginname", "login name", "screenname", "screen name", "account name")]
identifiers_pattern = [nlp(text) for text in ("attribute", "id", "ID", "code", "ID code")]
authentication_pattern = [nlp(text) for text in ("authentication", "authenticity", "certification", "verification", "attestation", "authenticator", "authenticators")]
time_pattern = [nlp(text) for text in ("time", "date", "moment", "present", "pace", "moment")]
unauthorized_pattern = [nlp(text) for text in ("unauthorized", "illegal", "illegitimate", "pirated", "unapproved", "unjustified", "unofficial")]
disclosure_pattern = [nlp(text) for text in ("disclosure", "acknowledgment", "admission", "exposure", "advertisement", "divulgation")]
network_pattern = [nlp(text) for text in ("network", "net", "networking", "internet", "Internet")]
wireless_pattern = [nlp(text) for text in ("wireless", "wifi", "Wi-Fi", "wireless networking")]
password_pattern = [nlp(text) for text in ("password", "passwords", "passcode", "passphrase")]
configuration_pattern = [nlp(text) for text in ("configuration", "composition")]
signatures_pattern = [nlp(text) for text in ("signature", "signatures", "digital signature", "electronic signature")]
certificates_pattern = [nlp(text) for text in ("certificate", "digital certificates", "authorization certificate", "public key certificates", "PKI", "X509", "X.509")]
revocation_pattern = [nlp(text) for text in ("revocation", "annulment", "cancellation")]
keys_pattern = [nlp(text) for text in ("key", "keys")]
algorithms_pattern = [nlp(text) for text in ("algorithm", "algorithms", "formula", "program")]
standard_pattern = [nlp(text) for text in ("standard", "standards", "specification", "specifications", "norm", "rule", "rules", "RFC")]
invalid_pattern = [nlp(text) for text in ("invalid", "false", "unreasonable", "inoperative")]
access_pattern = [nlp(text) for text in ("access", "connection", "entry", "entrance")]
blocking_pattern = [nlp(text) for text in ("blocking", "block", "blacklist", "blocklist", "close", "cut off", "deter", "prevent", "stop")]
notification_pattern = [nlp(text) for text in ("notification", "notifications", "notice", "warning")]
messages_pattern = [nlp(text) for text in ("message", "messages", "note", "news")]
untrusted_pattern = [nlp(text) for text in ("untrusted", "malicious", "unsafe")]
security_pattern = [nlp(text) for text in ("security", "secure", "securely", "protect", "defend", "guard")]
symmetric_pattern = [nlp(text) for text in ("symmetric", "symmetric crypto")]
asymmetric_pattern = [nlp(text) for text in ("asymmetric", "asymmetric crypto")]


matcher = PhraseMatcher(nlp.vocab)

matcher.add("USER", None, *users_pattern)
matcher.add("DEVICE", None, *devices_pattern)
matcher.add("ACCOUNT", None, *accounts_pattern)
matcher.add("IDENTIFIER", None, *identifiers_pattern)
matcher.add("AUTHENTICATION", None, *authentication_pattern)
matcher.add("TIME", None, *time_pattern)
matcher.add("UNAUTHORIZED", None, *unauthorized_pattern)
matcher.add("DISCLOSURE", None, *disclosure_pattern)
matcher.add("NETWORK", None, *network_pattern)
matcher.add("WIRELESS", None, *wireless_pattern)
matcher.add("PASSWORD", None, *password_pattern)
matcher.add("CONFIGURATION", None, *configuration_pattern)
matcher.add("SIGNATURE", None, *signatures_pattern)
matcher.add("CERTIFICATE", None, *certificates_pattern)
matcher.add("REVOCATION", None, *revocation_pattern)
matcher.add("KEY", None, *keys_pattern)
matcher.add("ALGORITHM", None, *algorithms_pattern)
matcher.add("STANDARD", None, *standard_pattern)
matcher.add("INVALID", None, *invalid_pattern)
matcher.add("ACCESS", None, *access_pattern)
matcher.add("BLOCKING", None, *blocking_pattern)
matcher.add("NOTIFICATION", None, *notification_pattern)
matcher.add("MESSAGE", None, *messages_pattern)
matcher.add("UNTRUSTED", None, *untrusted_pattern)
matcher.add("SECURITY", None, *security_pattern)
matcher.add("SYMMETRIC", None, *symmetric_pattern)
matcher.add("ASYMMETRIC", None, *asymmetric_pattern)

После добавления всех различных шаблонов к сопоставительному объекту объект matcher готов, чтобы вы могли делать прогнозы:

doc = nlp("Attackers can deny service to individual victims, such as by deliberately entering a wrong password enough consecutive times to cause the victims account to be locked, or they may overload the capabilities of a machine or network and block all users at once.")
    matches = matcher(doc)
    for match_id, start, end in matches:
        label = nlp.vocab.strings[match_id]
        span = doc[start:end]
        print(f"label:{label}, start:{start}, end:{end}, text:{span.text}")

Вывод

label:PASSWORD, start:15, end:16, text:password
label:ACCOUNT, start:23, end:24, text:account
label:DEVICE, start:36, end:37, text:machine
label:NETWORK, start:38, end:39, text:network
label:BLOCKING, start:40, end:41, text:block

Надеюсь, это поможет.

person Raqib    schedule 26.06.2020
comment
Спасибо за ответ @raqib. Однако, независимо от приложения, я не понимаю, почему обучение модели spaCy NER с предоставленным набором данных и кодом все еще не соответствует требованиям. Любые идеи по этому поводу приветствуются. - person Paul; 29.06.2020
comment
P.S. Прошло несколько дней, так что я могу немного отдохнуть o_two = o_one + len(str(subdoc)) должно быть `o_two = o_one + len (str (subdoc)) + 1`. Думаю, это повлияло на нер-лейблы. Можете ли вы дважды проверить это и посмотреть, улучшится ли что-нибудь? - person Raqib; 29.06.2020
comment
Спасибо за предложение @raqib. Я проверил то, что вы предложили, но это изменение ухудшает производительность на тренировочной выборке. Сейчас раздача: [('USER', 24), ('TIME', 19), ('SECURITY', 12), ('DEVICE', 7), ('IDENTIFIER', 7), ('CONFIGURATION', 5), ('STANDARD', 5), ('ALGORITHM', 4), ('MESSAGE', 4), ('CRYPTO', 3), ('BLOCKING', 3), ('NETWORK', 2), ('SIGNATURE', 2), ('UNAUTHORIZED', 1), ('NOTIFICATION', 1)]. Я ожидал чего-то намного лучшего, учитывая, что плато потерь около 150, а для предыдущего метода было около 500. - person Paul; 01.07.2020
comment
Интересно, есть ли у @syllogism_ какие-нибудь комментарии по этому поводу. Спасибо. - person Paul; 01.07.2020
comment
Интересно, есть ли у @Ines Montani какие-либо комментарии по этому поводу. Спасибо. - person Paul; 01.07.2020
comment
Интересно, есть ли у @Sofie VL какие-либо комментарии по этому поводу. Спасибо. - person Paul; 01.07.2020

Недостаточная подгонка может быть связана с тем, что объемные пустые модели слишком малы, чтобы хорошо работать в вашей ситуации. По моему опыту, объемные пустые модели имеют размер около 5 МБ, что мало (особенно если сравнивать его с размером пространственных предварительно обученных моделей, который может составлять около 500 МБ).

Действительно, у вас есть 27 разных ярлыков и много данных.

Не знаю, можно ли с нуля создавать более объемные модели. Ответы приветствуются.

person Ooona    schedule 29.01.2021