-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnlp_utils.py
More file actions
159 lines (141 loc) · 6.08 KB
/
Copy pathnlp_utils.py
File metadata and controls
159 lines (141 loc) · 6.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
"""
Biomedical NLP layer for the Knowledge Graph Explorer.
--------------------------------------------------------
Adds three things the original SVO extractor didn't have:
1. Entity typing — classifies each extracted span as DISEASE, GENE_PROTEIN,
DRUG_CHEMICAL, ANATOMY, or PROCESS using a curated neurodegenerative-disease
lexicon, so the graph can color-code nodes instead of drawing every node
the same color.
2. Synonym normalization — collapses "AD", "Alzheimer disease", and
"Alzheimer's disease" into one canonical node instead of three.
3. Negation detection — flags relationships like "X does not activate Y" so
they can be rendered differently from positive claims instead of being
silently treated the same way.
"""
import re
import spacy
# ---- Entity lexicon --------------------------------------------------
# Longest-match-first term lists per category. This is intentionally a
# lightweight, dependency-free alternative to a full biomedical NER model
# (e.g. scispaCy) so the app keeps deploying cleanly on Streamlit Community
# Cloud. Swap in scispaCy's en_ner_bc5cdr_md if you want model-based typing.
ENTITY_LEXICON = {
"DISEASE": [
"alzheimer's disease", "alzheimer disease", "parkinson's disease",
"parkinson disease", "amyotrophic lateral sclerosis",
"huntington's disease", "huntington disease",
"frontotemporal dementia", "lewy body dementia", "multiple sclerosis",
"motor neuron disease", "parkinsonism", "dementia", "als",
],
"GENE_PROTEIN": [
"amyloid-beta", "amyloid beta", "amyloid precursor protein", "tau",
"alpha-synuclein", "a-synuclein", "huntingtin", "apoe", "app",
"psen1", "psen2", "lrrk2", "gba", "c9orf72", "sod1", "tdp-43",
"parkin", "pink1", "prion protein",
],
"DRUG_CHEMICAL": [
"levodopa", "donepezil", "memantine", "riluzole", "edaravone",
"aducanumab", "lecanemab", "dopamine", "acetylcholine", "glutamate",
"rivastigmine", "galantamine",
],
"ANATOMY": [
"substantia nigra", "hippocampus", "cortex", "basal ganglia",
"striatum", "neuron", "microglia", "synapse", "brainstem",
"cerebellum", "axon",
],
"PROCESS": [
"neuroinflammation", "aggregation", "phosphorylation", "apoptosis",
"autophagy", "oxidative stress", "neurodegeneration",
"mitochondrial dysfunction", "protein misfolding",
],
}
# Sorted longest-first so "amyloid precursor protein" matches before "amyloid".
_LEXICON_FLAT = sorted(
((term, category) for category, terms in ENTITY_LEXICON.items() for term in terms),
key=lambda pair: len(pair[0]),
reverse=True,
)
ENTITY_COLORS = {
"DISEASE": "#e8746b",
"GENE_PROTEIN": "#6ba5e8",
"DRUG_CHEMICAL": "#7fd18f",
"ANATOMY": "#d9b45c",
"PROCESS": "#b38ce8",
"OTHER": "#b5b8c2",
}
SYNONYM_MAP = {
"ad": "alzheimer's disease",
"alzheimer disease": "alzheimer's disease",
"pd": "parkinson's disease",
"parkinson disease": "parkinson's disease",
"als": "amyotrophic lateral sclerosis",
"hd": "huntington's disease",
"huntington disease": "huntington's disease",
"ftd": "frontotemporal dementia",
"lbd": "lewy body dementia",
"ms": "multiple sclerosis",
"amyloid beta": "amyloid-beta",
"amyloid precursor protein": "app",
"a-synuclein": "alpha-synuclein",
}
NEGATION_CUES = {"no", "not", "n't", "without", "never", "fail", "lack", "absent"}
def classify_entity(text):
"""Return the lexicon category for a span, or 'OTHER' if unmatched."""
lowered = text.lower()
for term, category in _LEXICON_FLAT:
if term in lowered:
return category
return "OTHER"
def normalize_text(text):
"""Lowercase, strip leading articles, and canonicalize known synonyms
so 'the plaque phenotype' / 'plaque phenotype' and 'AD' / 'Alzheimer's
disease' collapse into the same graph node."""
text = text.strip().lower()
text = re.sub(r"^(the|a|an|this|that|these|those)\s+", "", text)
return SYNONYM_MAP.get(text, text)
def get_span_for_token(token):
for chunk in token.doc.noun_chunks:
if token.i >= chunk.start and token.i < chunk.end:
return chunk.text
return token.text
def is_negated(verb_token):
"""Check the verb's direct children for a dependency-parsed negation
(handles 'does not activate'), then fall back to scanning the surface
text of the sentence for a negation cue word (catches 'fails to
activate', 'lack of activation', which the neg dep tag often misses)."""
for child in verb_token.children:
if child.dep_ == "neg":
return True
sent_text = verb_token.sent.text.lower()
return any(cue in sent_text for cue in NEGATION_CUES)
def extract_triples(text, nlp, source_id=None):
"""Extract (subject, verb, object) relationships from text, enriched
with entity type, negation, and provenance (source_id + sentence) so
the UI can show *why* an edge exists, not just that it exists."""
doc = nlp(text)
triples = []
for sent in doc.sents:
subj, verb, obj = None, None, None
for token in sent:
if token.dep_ in ("nsubj", "nsubjpass") and token.head.pos_ == "VERB":
subj = token
verb = token.head
if token.dep_ in ("dobj", "pobj", "attr") and verb is not None:
if token.head == verb or token.head.head == verb:
obj = token
if subj is not None and verb is not None and obj is not None:
subj_text = normalize_text(get_span_for_token(subj))
obj_text = normalize_text(get_span_for_token(obj))
if subj_text == obj_text:
continue
triples.append({
"subject": subj_text,
"subject_type": classify_entity(subj_text),
"verb": verb.lemma_,
"object": obj_text,
"object_type": classify_entity(obj_text),
"negated": is_negated(verb),
"sentence": sent.text.strip(),
"source_id": source_id,
})
return triples