How to create and use a custom addon¶
This part of the tutorial focuses on addon creations.
Addons are components that run in addition to (and after) the core components (tagging, normalization, ner, and linking).
They can be provided by the core library (e.g MetaCAT or RelCAT which are available as optional addons when isntalling medcat) or by external packages.
An addon has full access to the output of the model. So it can change, add, or remove entities to the output. Or, it can add extra data / context to entities.
Example of a post-processing addon¶
We will create an example addon that does some post processing. For the example, we will <filter out all names that have 'no' in them?>, but for actual use cases you may need a more sophisticated system for this. But we do imagine someone might want to add an additional addon that uses the output of MetaCAT to filter out concepts that are negated and/or not related to the patient at hand.
from medcat.components.addons.addons import AddonComponent
from medcat.components.addons.addons import register_addon
from medcat.tokenizing.tokenizers import BaseTokenizer
from medcat.tokenizing.tokens import MutableDocument, MutableEntity
from medcat.config.config import ComponentConfig
from medcat.cdb import CDB
from medcat.vocab import Vocab
_ADDON_NAME = 'post-filter'
class ConfigPostFilter(ComponentConfig):
# inherited
comp_name: str = _ADDON_NAME
# specific to this
filter_string: str = 'no'
def should_keep(self, entity: MutableEntity) -> bool:
return self.filter_string not in entity.text.lower()
ADDON_DATA_PATH_DOC = 'post-filter-doc-data'
ADDON_DATA_PATH_ENT = 'post-filter-entity-data'
class PostFilteringAddon(AddonComponent):
addon_type = _ADDON_NAME
output_key = _ADDON_NAME
config: ConfigPostFilter
def __init__(self, config: ConfigPostFilter, base_tokenizer: BaseTokenizer) -> None:
self.config = config
# NOTE: allows us to have multiple with different filters if we like
self._name = f"F-{config.filter_string}"
self._init_data_paths(base_tokenizer)
@classmethod
def create_new_component(
cls, cnf: ConfigPostFilter, tokenizer: BaseTokenizer,
cdb: CDB, vocab: Vocab, model_load_path: str | None,
) -> 'PostFilteringAddon':
return cls(cnf, tokenizer)
@property
def name(self) -> str:
return str(self._name)
def _inference(self, doc: MutableDocument) -> MutableDocument:
ents_start = len(doc.linked_ents)
for ent in list(doc.linked_ents):
if not self.config.should_keep(ent):
# if not to keep, remove
doc.linked_ents.remove(ent)
# but mark in entity that it was removed
ent.set_addon_data(
ADDON_DATA_PATH_ENT,
# kept?, reason
(False, "not allowed by filter")
)
else:
ent.set_addon_data(
ADDON_DATA_PATH_ENT,
# kept?, reason
(True, "allowed by filter")
)
ents_end = len(doc.linked_ents)
doc.set_addon_data(
ADDON_DATA_PATH_DOC, (ents_start, ents_end)
)
return doc
def __call__(self, doc: MutableDocument) -> MutableDocument:
return self._inference(doc)
def _init_data_paths(self, base_tokenizer: BaseTokenizer):
# NOTE: only stuff from the entity class will propagate into
# the output json in the end
base_tokenizer.get_entity_class().register_addon_path(
ADDON_DATA_PATH_ENT, def_val=None, force=True)
# if you wish something saved on a per document basis, use this:
base_tokenizer.get_doc_class().register_addon_path(
ADDON_DATA_PATH_DOC, def_val=None, force=True)
# NOTE: the per-document data does not get automatically written
# to the output of get_entitites
# NOTE: this will need to be implemented and return True
# if you wish the output included in get_entities
@property
def include_in_output(self) -> bool:
return True
def get_output_key_val(self, ent: MutableEntity
) -> tuple[str, tuple[bool, str]]:
return self.output_key, ent.get_addon_data(ADDON_DATA_PATH_ENT)
# register component
register_addon(PostFilteringAddon.addon_type, PostFilteringAddon.create_new_component)
! mkdir -P models/
! wget -N https://cogstack-medcat-example-models.s3.eu-west-2.amazonaws.com/medcat-example-models/medmen_wstatus_2021_oct.zip -P models/
MODEL_PATH = "models/"
import os
from medcat.cat import CAT
model_path = os.path.join("models", "medmen_wstatus_2021_oct.zip")
cat = CAT.load_model_pack(model_path)
# need to fix on a legacy model:
cat.config.components.linking.train = False
# create the addon
from medcat.components.addons.addons import create_addon
cnf = ConfigPostFilter()
# this also ensure that it was correctly registered
# and that the standardised hook to create the addon is working
# as intended
addon = create_addon(
PostFilteringAddon.addon_type,
cnf,
cat.pipe.tokenizer,
cat.cdb,
cat.vocab,
None,
)
# add the addon to the model
cat.add_addon(addon)
print("PIPE:\n", cat.describe_pipeline())
mkdir: illegal option -- P usage: mkdir [-pv] [-m mode] directory_name ... --2026-08-14 14:40:58-- https://cogstack-medcat-example-models.s3.eu-west-2.amazonaws.com/medcat-example-models/medmen_wstatus_2021_oct.zip Resolving cogstack-medcat-example-models.s3.eu-west-2.amazonaws.com (cogstack-medcat-example-models.s3.eu-west-2.amazonaws.com)... 52.95.148.18, 3.5.245.207, 52.95.142.42, ... Connecting to cogstack-medcat-example-models.s3.eu-west-2.amazonaws.com (cogstack-medcat-example-models.s3.eu-west-2.amazonaws.com)|52.95.148.18|:443... connected. HTTP request sent, awaiting response... 304 Not Modified File ‘models/medmen_wstatus_2021_oct.zip’ not modified on server. Omitting download.
Doing legacy conversion on CAT (at 'models/medmen_wstatus_2021_oct'). Set the environment variable MEDCAT_AVOID_LECACY_CONVERSION to `True` to avoid this. Missing class medcat.config.weighted_average, replacing with LegacyClassNotFound.
Conditions False False IN {'T125', 'T057', 'T103', 'T086', 'T040', 'T037', 'T025', 'T070', 'T017', 'T095', 'T067', 'T047', 'T097', 'T109', 'T014', 'T007', 'T167', 'T042', 'T013', 'T030', 'T184', 'T064', 'T096', 'T102', 'T016', 'T056', 'T114', 'T075', 'T201', 'T100', 'T029', 'T099', 'T031', 'T092', 'T049', 'T120', 'T043', 'T019', 'T044', 'T041', 'T171', 'T066', 'T169', 'T038', 'T023', 'T170', 'T191', 'T020', 'T116', 'T127', 'T080', 'T093', 'T026', 'T050', 'T098', 'T065', 'T011', 'T104', 'T131', 'T072', 'T052', 'T051', 'T090', 'T085', 'T078', 'T081', 'T054', 'T123', 'T069', 'T028', 'T073', 'T091', 'T053', 'unk', 'T015', 'T004', 'T061', 'T121', 'T079', 'T002', 'T055', 'T129', 'T203', 'T033', 'T077', 'T190', 'T034', 'T204', 'T122', 'T010', 'T060', 'T089', 'T005', 'T001', 'T168', 'T018', 'T024', 'T059', 'T083', 'T087', 'T194', 'T039', 'T032', 'T071', 'T012', 'T021', 'T022', 'T196', 'T046', 'T094', 'T101', 'T048', 'T082', 'T197', 'T200', 'T130', 'T062', 'T185', 'T063', 'T195', 'T008', 'T126', 'T045', 'T068', 'T192', 'T058', 'T074'}
Got TypeID2name for 127 TypeIDs out of 127
Trying to set 'cdb_source_name' for 'General' but no such attribute Trying to set 'weighted_average_function' for 'Linking' but no such attribute Optional path 'version.description' not found in old config. Ignoring Optional path 'version.id' not found in old config. Ignoring Optional path 'version.ontology' not found in old config. Ignoring /Users/martratas/Documents/CogStack/.MedCAT.nosync/monorepo-nlp/medcat-v2-tutorials/.venv312/lib/python3.12/site-packages/spacy/util.py:969: UserWarning: [W095] Model 'en_core_web_md' (3.1.0) was trained with spaCy v3.1.0 and may not be 100% compatible with the current version (3.8.11). If you see errors or degraded performance, download a newer compatible model or retrain your custom model with the current spaCy version. For more details and available updates, run: python -m spacy validate warnings.warn(warn_msg) /Users/martratas/Documents/CogStack/.MedCAT.nosync/monorepo-nlp/medcat-v2-tutorials/.venv312/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html from .autonotebook import tqdm as notebook_tqdm
PIPE:
{'core': {'tagging': {'name': 'tag-and-skip-tagger', 'provider': 'medcat'}, 'token_normalizing': {'name': 'token_normalizer', 'provider': 'medcat'}, 'ner': {'name': 'cat_ner', 'provider': 'medcat'}, 'linking': {'name': 'medcat2_linker', 'provider': 'medcat'}}, 'addons': [{'name': 'Status', 'provider': 'medcat'}, {'name': 'F-no', 'provider': 'medcat'}]}
Now we use it¶
Now that we have a model with the addon, we can use the entire pipe with our addon in it.
from pprint import pprint
text = (
# normal has 'no' so should be removed
"John felt normal. "
# fever does not so should be kept
"He had no fever."
)
# let's look at the document for the addon data
doc = cat(text)
doc_data = doc.get_addon_data(ADDON_DATA_PATH_DOC)
# NOTE: again, this doesn't get written into the output of get_entities
print("Per doc data:", doc_data)
print("Inspecting raw NER entities")
# we can still investiaget the raw NER entity
# these will include the ones that were later rejected
# by our filter
for ent in doc.ner_ents:
addon_output = cat.get_addon_output(ent)
print("Entity", ent, ent.cui, addon_output['post-filter'])
print("Now for regular get_entities()")
# NOTE: you don't normally want to call the above AND this - that
# would run the entire pipe ofer the same text twice.
# So stick to one or the other as needed
ents = cat.get_entities(text)['entities']
pprint(ents)
Per doc data: (2, 1)
Inspecting raw NER entities
Entity M2W[T]:normal C0205307 (False, 'not allowed by filter')
Entity M2W[T]:fever C0015967 (True, 'allowed by filter')
Now for regular get_entities()
{1: {'acc': 0.7370152793602454,
'context_center': [],
'context_left': [],
'context_right': [],
'context_similarity': 0.7370152793602454,
'cui': 'C0015967',
'detected_name': 'fever',
'end': 33,
'id': 1,
'meta_anns': {'Status': {'confidence': 0.9999959468841553,
'name': 'Status',
'value': 'Other'}},
'post-filter': (True, 'allowed by filter'),
'pretty_name': 'Fever',
'source_value': 'fever',
'start': 28,
'type_ids': ['T184']}}