Ответ 1
Обзор решения
Хорошо, я бы подошел к проблеме с нескольких сторон. Здесь есть несколько отличных предложений, и на вашем месте я бы использовал ансамбль этих подходов (большинство голосов, метка прогнозирования, которая согласована более чем с 50% классификаторов в вашем двоичном случае).
Я думаю о следующих подходах:
- Активное обучение (пример подхода предоставлен мной ниже)
- Обратные ссылки MediaWiki предоставлены в качестве ответа @TavoGC
- Родовые категории SPARQL, предоставленные @Stanislav Kralin в качестве комментария к вашему вопросу, и/или родительские категории, предоставленные @Meena Nagarajan (эти две группы могут быть ансамблем сами по себе, исходя из их различий, но для этого вам придется связаться с создателями и сравните их результаты).
Таким образом, 2 из 3 должны согласиться с тем, что определенная концепция является медицинской, что сводит к минимуму вероятность ошибки в дальнейшем.
Пока мы на этом, я бы поспорил с подходом, представленным @ananand_v.singh в этом ответе, потому что:
- Метрика расстояния не должна быть евклидовой, косинусное сходство намного лучше метрики (используется, например, spaCy), так как она не учитывает величину векторов (и не должно быть так, как обучали word2vec или GloVe)
- если бы я правильно понял, было бы создано много искусственных кластеров, а нам нужно только два: медицинское и немедицинское. Кроме того, центр тяжести медицины не сосредоточен на самом лекарстве. Это создает дополнительные проблемы, скажем, центроид удален от медицины, и другие слова, такие как, скажем,
computer
илиhuman
(или любой другой, не подходящий по вашему мнению, к медицине), могут попасть в кластер. - трудно оценить результаты, тем более, дело строго субъективное. Кроме того, векторы слов трудно визуализировать и понять (приведение их к более низким измерениям [2D/3D] с использованием PCA/TSNE/аналогичных для многих слов) даст нам совершенно бессмысленные результаты [да, я пытался это сделать, PCA получает около 5% объясненной дисперсии для вашего более длинного набора данных, действительно, очень низкий]).
Основываясь на вышеупомянутых проблемах, я нашел решение с использованием активного обучения, которое является довольно забытым подходом к таким проблемам.
Активный подход к обучению
В этом подмножестве машинного обучения, когда нам трудно придумать точный алгоритм (например, что означает термин быть частью medical
категории), мы спрашиваем человека "эксперт" (на самом деле не обязательно быть экспертом), чтобы дать некоторые ответы.
Кодирование знаний
Как указывал anand_v.singh, векторы слов являются одним из наиболее многообещающих подходов, и я буду использовать их и здесь (хотя по-другому, а IMO будет намного чище и проще).
Я не собираюсь повторять его пункты в своем ответе, поэтому я добавлю свои два цента:
- Не используйте контекстуализированные встраивания слов как доступный в настоящее время уровень техники (например, BERT)
- Проверьте, сколько ваших понятий не имеют представления (например, представлено как вектор нулей). Это должно быть проверено (и проверено в моем коде, когда придет время, будет продолжено обсуждение), и вы можете использовать вложение, в котором присутствует большинство из них.
Измерение сходства с использованием spaCy
Этот класс измеряет сходство между medicine
закодированным как вектор слова "SpaCy GloVe", и любым другим понятием.
class Similarity:
def __init__(self, centroid, nlp, n_threads: int, batch_size: int):
# In our case it will be medicine
self.centroid = centroid
# spaCy 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 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 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
, который, по мнению эксперта, находит оптимальный порог 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 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 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 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 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
тяжести medicine
. Вы можете этого не хотеть (поскольку некоторые нишевые медицинские термины [аббревиатуры, такие как gpv
или другие) могут не иметь их представления), в таком случае вы можете усреднить только те векторы, которые отличаются от нуля.
Я знаю, что этот пост довольно длинный, поэтому, если у вас есть какие-либо вопросы, напишите их ниже.