Обзор решения
Хорошо, я бы подошел к проблеме с разных сторон.Здесь есть несколько замечательных предложений, и на вашем месте я бы использовал ансамбль этих подходов (большинство голосов, метка прогнозирования, которая согласована более чем с 50% классификаторов в вашем двоичном случае).
Я думаю о следующих подходах:
Таким образом, 2 из 3 должны согласиться с тем, что определенная концепция является медицинской, что сводит к минимуму вероятность ошибки в дальнейшем.
Пока мына это я быаргументируйте против подхода, представленного @ananand_v.singh в этот ответ , потому что:
- метрика расстояния не должна евклидово, косинусное сходство является гораздо лучшей метрикой (используется, например, spaCy ), поскольку оно не учитывает величину векторов (и не должно, именно так обучались word2vec или GloVe)
- было бы создано много искусственных скоплений, если бы я правильно понял, а нам нужно только два: лекарственное и немедицинское.Кроме того, центр тяжести лекарства не сосредоточен на самом лекарстве.Это создает дополнительные проблемы, скажем, что центроид удален от медицины, и другие слова, такие как, скажем,
computer
или human
(или любое другое, не подходящее, по вашему мнению, к медицине) могут попасть в кластер. - сложно оценить результаты, тем более, дело строго субъективное.Кроме того, векторы слов трудно визуализировать и понять (приведение их к более низким измерениям [2D / 3D] с использованием PCA / TSNE / подобных для многих слов) даст нам совершенно бессмысленные результаты [да, я пытался это сделать, PCAполучает около 5% объясненной дисперсии для вашего более длинного набора данных, действительно, очень низкий]).
Исходя из вышеперечисленных проблем, я нашел решение с использованием active learning , котороеЭто довольно забытый подход к таким задачам.
Активный подход к обучению
В этом подмножестве машинного обучения, когда нам трудно придумать точный алгоритм (например, что это значит длячтобы быть частью категории medical
), мы просим человека-эксперта (на самом деле не обязательно быть экспертом) дать несколько ответов.
Кодировка знаний
Как anand_v.singh отметил, что словосочетания являются одним из наиболее многообещающих подходов, и я буду использовать его и здесь (хотя и иначе, и IMO гораздо чище и проще).sier fashion).
Я не собираюсь повторять его пункты в своем ответе, поэтому я добавлю свои два цента:
- Не использовать контекстуализированныйвстраивание слов как доступный в настоящее время уровень техники (например, BERT )
- Проверьте, сколько из ваших понятий имеют нет представления (например, представлено как вектор нулей).Это должно быть проверено (и проверено в моем коде, когда придет время, будет продолжено обсуждение), и вы можете использовать вложение, в котором присутствует большинство из них.
Измерение сходства с использованием spaCy
Этот класс измеряет сходство между medicine
, закодированным как вектор слов GloCe spaCy, и любым другим понятием.
class Similarity:
def __init__(self, centroid, nlp, n_threads: int, batch_size: int):
# In our case it will be medicine
self.centroid = centroid
# spaCy's Language model (english), which will be used to return similarity to
# centroid of each concept
self.nlp = nlp
self.n_threads: int = n_threads
self.batch_size: int = batch_size
self.missing: typing.List[int] = []
def __call__(self, concepts):
concepts_similarity = []
# nlp.pipe is faster for many documents and can work in parallel (not blocked by GIL)
for i, concept in enumerate(
self.nlp.pipe(
concepts, n_threads=self.n_threads, batch_size=self.batch_size
)
):
if concept.has_vector:
concepts_similarity.append(self.centroid.similarity(concept))
else:
# If document has no vector, it's assumed to be totally dissimilar to centroid
concepts_similarity.append(-1)
self.missing.append(i)
return np.array(concepts_similarity)
Этот код будет возвращать число для каждой концепции, показывающее, насколько оно похоже на центроид.Кроме того, он записывает индексы понятий, отсутствующих в их представлении.Это можно назвать так:
import json
import typing
import numpy as np
import spacy
nlp = spacy.load("en_vectors_web_lg")
centroid = nlp("medicine")
concepts = json.load(open("concepts_new.txt"))
concepts_similarity = Similarity(centroid, nlp, n_threads=-1, batch_size=4096)(
concepts
)
Вы можете заменить свои данные вместо new_concepts.json
.
Посмотрите на spacy.load и обратите внимание, что я использовал en_vectors_web_lg
.Он состоит из 685.000 уникальных векторов слов (что очень много) и может работать из коробки для вашего случая.Вы должны загрузить его отдельно после установки spaCy, более подробную информацию можно получить по ссылкам выше.
Дополнительно Вы можете использовать несколько слов центроида , например, добавить такие слова, какdisease
или health
и усредните их векторы слов.Я не уверен, что это положительно скажется на вашем случае.
Другая возможность может заключаться в использовании нескольких центроидов и вычислении подобия между каждой концепцией и множеством центроидов.У нас может быть несколько порогов в этом случае, это может удалить некоторые ложных срабатываний , но может пропустить некоторые условия, которые можно считать похожими на medicine
.Более того, это значительно усложнит ситуацию, но если ваши результаты неудовлетворительны, вы должны рассмотреть два варианта выше (и только в этом случае не переходите к этому подходу без предварительной мысли).
Теперь мы имеемгрубая мера сходства понятий.Но что означает , что определенное понятие имеет 0,1 положительного сходства с медициной?Это понятие следует классифицировать как медицинский?Или, может быть, это уже слишком далеко?
Задание эксперта
Чтобы получить порог (ниже этого условия будут считаться немедицинскими), проще всего попросить человека классифицировать некоторые понятия длянас (и это то, что активное обучение о).Да, я знаю, что это действительно простая форма активного обучения, но я бы все равно считал ее такой.
Я написал класс с интерфейсом sklearn-like
, в котором человеку предлагалось классифицировать понятия до оптимального порога (или максимального числаитераций).
class ActiveLearner:
def __init__(
self,
concepts,
concepts_similarity,
max_steps: int,
samples: int,
step: float = 0.05,
change_multiplier: float = 0.7,
):
sorting_indices = np.argsort(-concepts_similarity)
self.concepts = concepts[sorting_indices]
self.concepts_similarity = concepts_similarity[sorting_indices]
self.max_steps: int = max_steps
self.samples: int = samples
self.step: float = step
self.change_multiplier: float = change_multiplier
# We don't have to ask experts for the same concepts
self._checked_concepts: typing.Set[int] = set()
# Minimum similarity between vectors is -1
self._min_threshold: float = -1
# Maximum similarity between vectors is 1
self._max_threshold: float = 1
# Let's start from the highest similarity to ensure minimum amount of steps
self.threshold_: float = 1
samples
аргумент описывает, сколько примеров будет показано эксперту во время каждой итерации (это максимум, он вернет меньше, если образцы уже были запрошеныили их недостаточно для отображения). step
представляет падение порога (мы начинаем с 1, что означает абсолютное сходство) в каждой итерации. change_multiplier
- есликонцепции ответов экспертов не связаны (или, по большей части, не связаны, так как возвращаются несколько из них), шаг умножается на это число с плавающей запятой.Он используется для точного определения точного порога между step
изменениями на каждой итерации. - понятия сортируются по их сходству (чем больше сходство понятий, тем выше)
Функцияниже запрашивает мнение эксперта и находит оптимальный порог на основе его ответов.
def _ask_expert(self, available_concepts_indices):
# Get random concepts (the ones above the threshold)
concepts_to_show = set(
np.random.choice(
available_concepts_indices, len(available_concepts_indices)
).tolist()
)
# Remove those already presented to an expert
concepts_to_show = concepts_to_show - self._checked_concepts
self._checked_concepts.update(concepts_to_show)
# Print message for an expert and concepts to be classified
if concepts_to_show:
print("\nAre those concepts related to medicine?\n")
print(
"\n".join(
f"{i}. {concept}"
for i, concept in enumerate(
self.concepts[list(concepts_to_show)[: self.samples]]
)
),
"\n",
)
return input("[y]es / [n]o / [any]quit ")
return "y"
Пример вопроса выглядит так:
Are those concepts related to medicine?
0. anesthetic drug
1. child and adolescent psychiatry
2. tertiary care center
3. sex therapy
4. drug design
5. pain disorder
6. psychiatric rehabilitation
7. combined oral contraceptive
8. family practitioner committee
9. cancer family syndrome
10. social psychology
11. drug sale
12. blood system
[y]es / [n]o / [any]quit y
... парсинг ответа эксперта:
# True - keep asking, False - stop the algorithm
def _parse_expert_decision(self, decision) -> bool:
if decision.lower() == "y":
# You can't go higher as current threshold is related to medicine
self._max_threshold = self.threshold_
if self.threshold_ - self.step < self._min_threshold:
return False
# Lower the threshold
self.threshold_ -= self.step
return True
if decision.lower() == "n":
# You can't got lower than this, as current threshold is not related to medicine already
self._min_threshold = self.threshold_
# Multiply threshold to pinpoint exact spot
self.step *= self.change_multiplier
if self.threshold_ + self.step < self._max_threshold:
return False
# Lower the threshold
self.threshold_ += self.step
return True
return False
И, наконец, целый кодовый код ActiveLearner
, который, по мнению эксперта, находит оптимальный порог подобия:
class ActiveLearner:
def __init__(
self,
concepts,
concepts_similarity,
samples: int,
max_steps: int,
step: float = 0.05,
change_multiplier: float = 0.7,
):
sorting_indices = np.argsort(-concepts_similarity)
self.concepts = concepts[sorting_indices]
self.concepts_similarity = concepts_similarity[sorting_indices]
self.samples: int = samples
self.max_steps: int = max_steps
self.step: float = step
self.change_multiplier: float = change_multiplier
# We don't have to ask experts for the same concepts
self._checked_concepts: typing.Set[int] = set()
# Minimum similarity between vectors is -1
self._min_threshold: float = -1
# Maximum similarity between vectors is 1
self._max_threshold: float = 1
# Let's start from the highest similarity to ensure minimum amount of steps
self.threshold_: float = 1
def _ask_expert(self, available_concepts_indices):
# Get random concepts (the ones above the threshold)
concepts_to_show = set(
np.random.choice(
available_concepts_indices, len(available_concepts_indices)
).tolist()
)
# Remove those already presented to an expert
concepts_to_show = concepts_to_show - self._checked_concepts
self._checked_concepts.update(concepts_to_show)
# Print message for an expert and concepts to be classified
if concepts_to_show:
print("\nAre those concepts related to medicine?\n")
print(
"\n".join(
f"{i}. {concept}"
for i, concept in enumerate(
self.concepts[list(concepts_to_show)[: self.samples]]
)
),
"\n",
)
return input("[y]es / [n]o / [any]quit ")
return "y"
# True - keep asking, False - stop the algorithm
def _parse_expert_decision(self, decision) -> bool:
if decision.lower() == "y":
# You can't go higher as current threshold is related to medicine
self._max_threshold = self.threshold_
if self.threshold_ - self.step < self._min_threshold:
return False
# Lower the threshold
self.threshold_ -= self.step
return True
if decision.lower() == "n":
# You can't got lower than this, as current threshold is not related to medicine already
self._min_threshold = self.threshold_
# Multiply threshold to pinpoint exact spot
self.step *= self.change_multiplier
if self.threshold_ + self.step < self._max_threshold:
return False
# Lower the threshold
self.threshold_ += self.step
return True
return False
def fit(self):
for _ in range(self.max_steps):
available_concepts_indices = np.nonzero(
self.concepts_similarity >= self.threshold_
)[0]
if available_concepts_indices.size != 0:
decision = self._ask_expert(available_concepts_indices)
if not self._parse_expert_decision(decision):
break
else:
self.threshold_ -= self.step
return self
В общем, вам придется ответить на некоторые вопросы вручную, но этоподход намного более точен , на мой взгляд.
Более того, вам не нужно проходить через все сэмплы, только небольшую часть.Вы можете решить, сколько образцов составляют медицинский термин (следует ли считать 40 медицинских образцов и 10 немедицинских образцов медицинскими?), Что позволит вам настроить этот подход в соответствии с вашими предпочтениями.Если есть выброс (скажем, 1 образец из 50 не является медицинским), я бы посчитал, что порог все еще действителен.
Еще раз: Этот подход следует смешать сдругие, чтобы минимизировать вероятность неправильной классификации.
Классификатор
Когда мы получим порог от эксперта, классификация будет мгновенной, вот простой класс для классификации:
class Classifier:
def __init__(self, centroid, threshold: float):
self.centroid = centroid
self.threshold: float = threshold
def predict(self, concepts_pipe):
predictions = []
for concept in concepts_pipe:
predictions.append(self.centroid.similarity(concept) > self.threshold)
return predictions
И для краткости, вот окончательный исходный код:
import json
import typing
import numpy as np
import spacy
class Similarity:
def __init__(self, centroid, nlp, n_threads: int, batch_size: int):
# In our case it will be medicine
self.centroid = centroid
# spaCy's Language model (english), which will be used to return similarity to
# centroid of each concept
self.nlp = nlp
self.n_threads: int = n_threads
self.batch_size: int = batch_size
self.missing: typing.List[int] = []
def __call__(self, concepts):
concepts_similarity = []
# nlp.pipe is faster for many documents and can work in parallel (not blocked by GIL)
for i, concept in enumerate(
self.nlp.pipe(
concepts, n_threads=self.n_threads, batch_size=self.batch_size
)
):
if concept.has_vector:
concepts_similarity.append(self.centroid.similarity(concept))
else:
# If document has no vector, it's assumed to be totally dissimilar to centroid
concepts_similarity.append(-1)
self.missing.append(i)
return np.array(concepts_similarity)
class ActiveLearner:
def __init__(
self,
concepts,
concepts_similarity,
samples: int,
max_steps: int,
step: float = 0.05,
change_multiplier: float = 0.7,
):
sorting_indices = np.argsort(-concepts_similarity)
self.concepts = concepts[sorting_indices]
self.concepts_similarity = concepts_similarity[sorting_indices]
self.samples: int = samples
self.max_steps: int = max_steps
self.step: float = step
self.change_multiplier: float = change_multiplier
# We don't have to ask experts for the same concepts
self._checked_concepts: typing.Set[int] = set()
# Minimum similarity between vectors is -1
self._min_threshold: float = -1
# Maximum similarity between vectors is 1
self._max_threshold: float = 1
# Let's start from the highest similarity to ensure minimum amount of steps
self.threshold_: float = 1
def _ask_expert(self, available_concepts_indices):
# Get random concepts (the ones above the threshold)
concepts_to_show = set(
np.random.choice(
available_concepts_indices, len(available_concepts_indices)
).tolist()
)
# Remove those already presented to an expert
concepts_to_show = concepts_to_show - self._checked_concepts
self._checked_concepts.update(concepts_to_show)
# Print message for an expert and concepts to be classified
if concepts_to_show:
print("\nAre those concepts related to medicine?\n")
print(
"\n".join(
f"{i}. {concept}"
for i, concept in enumerate(
self.concepts[list(concepts_to_show)[: self.samples]]
)
),
"\n",
)
return input("[y]es / [n]o / [any]quit ")
return "y"
# True - keep asking, False - stop the algorithm
def _parse_expert_decision(self, decision) -> bool:
if decision.lower() == "y":
# You can't go higher as current threshold is related to medicine
self._max_threshold = self.threshold_
if self.threshold_ - self.step < self._min_threshold:
return False
# Lower the threshold
self.threshold_ -= self.step
return True
if decision.lower() == "n":
# You can't got lower than this, as current threshold is not related to medicine already
self._min_threshold = self.threshold_
# Multiply threshold to pinpoint exact spot
self.step *= self.change_multiplier
if self.threshold_ + self.step < self._max_threshold:
return False
# Lower the threshold
self.threshold_ += self.step
return True
return False
def fit(self):
for _ in range(self.max_steps):
available_concepts_indices = np.nonzero(
self.concepts_similarity >= self.threshold_
)[0]
if available_concepts_indices.size != 0:
decision = self._ask_expert(available_concepts_indices)
if not self._parse_expert_decision(decision):
break
else:
self.threshold_ -= self.step
return self
class Classifier:
def __init__(self, centroid, threshold: float):
self.centroid = centroid
self.threshold: float = threshold
def predict(self, concepts_pipe):
predictions = []
for concept in concepts_pipe:
predictions.append(self.centroid.similarity(concept) > self.threshold)
return predictions
if __name__ == "__main__":
nlp = spacy.load("en_vectors_web_lg")
centroid = nlp("medicine")
concepts = json.load(open("concepts_new.txt"))
concepts_similarity = Similarity(centroid, nlp, n_threads=-1, batch_size=4096)(
concepts
)
learner = ActiveLearner(
np.array(concepts), concepts_similarity, samples=20, max_steps=50
).fit()
print(f"Found threshold {learner.threshold_}\n")
classifier = Classifier(centroid, learner.threshold_)
pipe = nlp.pipe(concepts, n_threads=-1, batch_size=4096)
predictions = classifier.predict(pipe)
print(
"\n".join(
f"{concept}: {label}"
for concept, label in zip(concepts[20:40], predictions[20:40])
)
)
После ответа на некоторые вопросы с порогом 0,1 (все, что находится между [-1, 0.1)
считается немедицинским, в то время как [0.1, 1]
считается медицинским), я получил следующие результаты:
kartagener s syndrome: True
summer season: True
taq: False
atypical neuroleptic: True
anterior cingulate: False
acute respiratory distress syndrome: True
circularity: False
mutase: False
adrenergic blocking drug: True
systematic desensitization: True
the turning point: True
9l: False
pyridazine: False
bisoprolol: False
trq: False
propylhexedrine: False
type 18: True
darpp 32: False
rickettsia conorii: False
sport shoe: True
Как вы можете видетьэтот подход далек от совершенства, поэтому в последнем разделе описаны возможные улучшения:
Возможные улучшения
Как уже упоминалось в начале, использование моего подхода в сочетании с другими ответами, вероятно, оставило бы такие идеи, как sport shoe
принадлежность к medicine
и подход к активному обучению были бы скорее решающим голосом в случае различий между двумя упомянутыми выше эвристиками.
Мы могли бы также создать ансамбль активного обучения.Вместо одного порога, скажем 0,1, мы бы использовали несколько из них (либо увеличивая, либо уменьшая), скажем, это 0.1, 0.2, 0.3, 0.4, 0.5
.
Скажем, sport shoe
получает, для каждого порога это соответствует True/False
как это:
True True False False False
,
Делая большинство голосов, мы отметили бы его non-medical
3 из 2 голосов.Кроме того, слишком строгий порог также уменьшил бы, если бы пороговые значения ниже этого превышали его (если True/False
будет выглядеть так: True True True False False
).
Окончательное возможное улучшение, которое я придумал: В приведенном выше коде я использую вектор Doc
, представляющий собой вектор слов, создающий концепцию.Скажем, пропущено одно слово (векторы, состоящие из нулей), в таком случае оно будет отталкиваться от medicine
центроида.Вы можете этого не хотеть (поскольку некоторые нишевые медицинские термины [такие сокращения, как gpv
или другие) могут не иметь их представления), в таком случае вы можете усреднить только те векторы, которые отличаются от нуля.
Я знаю, что это сообщение довольно длинное, поэтому, если у вас есть какие-либо вопросы, напишите их ниже.