Código
Contents
5.3. Código#
Note
El proyecto completo está disponible en el repositorio MedDocAn de nuestro github donde todo el código así como los experimentos del proyecto son disponibles.
5.3.1. Meddocan pipeline#
Una de las tareas es obtener un preprocesso correcto de los informes clínicos a través de un objeto spacy.tokens.Doc
a partir de cualquier cadena de caracteres. Por ello utilizaremos el meddocan.language.pipeline.meddocan_pipeline
creado con la ayuda de la biblioteca spaCy para adaptarlo a nuestras necesidades. El código relativo a la creación del pipeline se puede encontrar en el paquete meddocan/language.
Por otra parte, tenemos que poder leer y extraer la información relevante de los documentos provisto en el formato Brat. Es decir que cada informe clínico esta compuesto de 2 ficheros. Uno contiene el texto bruto y el otro las anotaciones. El código relativo de esa parte se encuentra en el paquete meddocan/data.
Para ver cómo funciona, seleccionamos un informe médico gracias al objeto meddocan.data.docs_iterators.GsDocs
que permite acceder a los documentos del conjunto de datos meddocan directamente como objetos spacy.tokens.Doc
con varios atributos específicos.
from pathlib import Path
from typing import List, Tuple
import pandas as pd
from spacy import displacy
from meddocan.data import meddocan_zip, ArchiveFolder
from meddocan.data.containers import BratAnnotations, BratFilesPair, BratSpan
from meddocan.data.docs_iterators import GsDocs
gs_docs = GsDocs(ArchiveFolder.train)
docs_with_brat_pair = iter(gs_docs)
doc_with_brat_pair = next(docs_with_brat_pair)
El objeto doc_with_brat_pair
creado por GsDocs
tiene 2 atributos.
[attr for attr in vars(doc_with_brat_pair).keys()]
['brat_files_pair', 'doc']
El atributo brat_files_pair
es un objeto meddocan.data.docs_iterators.BratFilesPair
que indica la ubicación de los ficheros originales correspondiendo al atributo doc
.
pd.DataFrame(
[type(doc_with_brat_pair.brat_files_pair).__qualname__,
doc_with_brat_pair.brat_files_pair.ann.name,
doc_with_brat_pair.brat_files_pair.txt.name],
index=["type", "txt", "ann"]
).T
type | txt | ann | |
---|---|---|---|
0 | BratFilesPair | S0004-06142005000500011-1.ann | S0004-06142005000500011-1.txt |
Lo que hace GsDocs
es crear un objeto Doc
a partir de un objeto meddocan.data.docs_iterators.DocWithBratPair
utilizando el MedocanPipeline
.
En una primera fase el MeddocanPipeline
recibe el texto contenido en el fichero original S0004-06142005000500011-1.txt como argumento.
Miramos el contenido del fichero utilizando el atributo doc
.
gold = doc_with_brat_pair.doc
gold
Datos del paciente.
Nombre: Ernesto.
Apellidos: Rivera Bueno.
NHC: 368503.
NASS: 26 63514095.
Domicilio: Calle Miguel Benitez 90.
Localidad/ Provincia: Madrid.
CP: 28016.
Datos asistenciales.
Fecha de nacimiento: 03/03/1946.
País: España.
Edad: 70 años Sexo: H.
Fecha de Ingreso: 12/12/2016.
Médico: Ignacio Navarro Cuéllar NºCol: 28 28 70973.
Informe clínico del paciente: Paciente de 70 años de edad, minero jubilado, sin alergias medicamentosas conocidas, que presenta como antecedentes personales: accidente laboral antiguo con fracturas vertebrales y costales; intervenido de enfermedad de Dupuytren en mano derecha y by-pass iliofemoral izquierdo; Diabetes Mellitus tipo II, hipercolesterolemia e hiperuricemia; enolismo activo, fumador de 20 cigarrillos / día.
Es derivado desde Atención Primaria por presentar hematuria macroscópica postmiccional en una ocasión y microhematuria persistente posteriormente, con micciones normales.
En la exploración física presenta un buen estado general, con abdomen y genitales normales; tacto rectal compatible con adenoma de próstata grado I/IV.
En la analítica de orina destaca la existencia de 4 hematíes/ campo y 0-5 leucocitos/campo; resto de sedimento normal.
Hemograma normal; en la bioquímica destaca una glucemia de 169 mg/dl y triglicéridos de 456 mg/dl; función hepática y renal normal. PSA de 1.16 ng/ml.
Las citologías de orina son repetidamente sospechosas de malignidad.
En la placa simple de abdomen se valoran cambios degenerativos en columna lumbar y calcificaciones vasculares en ambos hipocondrios y en pelvis.
La ecografía urológica pone de manifiesto la existencia de quistes corticales simples en riñón derecho, vejiga sin alteraciones con buena capacidad y próstata con un peso de 30 g.
En la UIV se observa normofuncionalismo renal bilateral, calcificaciones sobre silueta renal derecha y uréteres arrosariados con imágenes de adición en el tercio superior de ambos uréteres, en relación a pseudodiverticulosis ureteral. El cistograma demuestra una vejiga con buena capacidad, pero paredes trabeculadas en relación a vejiga de esfuerzo. La TC abdominal es normal.
La cistoscopia descubre la existencia de pequeñas tumoraciones vesicales, realizándose resección transuretral con el resultado anatomopatológico de carcinoma urotelial superficial de vejiga.
Remitido por: Ignacio Navarro Cuéllar c/ del Abedul 5-7, 2º dcha 28036 Madrid, España E-mail: nnavcu@hotmail.com.
En una segunda fase se le asigna las entidades extraídas del fichero S0004-06142005000500011-1.ann utilizando el método spacy.tokens.Doc.set_ents
.
Miramos el fichero al formato .brat que contiene las anotaciones para hacerse una idea.
print(doc_with_brat_pair.brat_files_pair.ann.read_text())
T1 FECHAS 215 225 03/03/1946
T2 CORREO_ELECTRONICO 2421 2439 nnavcu@hotmail.com
T3 PAIS 2406 2412 España
T4 TERRITORIO 2398 2404 Madrid
T5 TERRITORIO 2392 2397 28036
T6 CALLE 2365 2391 c/ del Abedul 5-7, 2º dcha
T7 NOMBRE_PERSONAL_SANITARIO 303 326 Ignacio Navarro Cuéllar
T8 NOMBRE_PERSONAL_SANITARIO 2341 2364 Ignacio Navarro Cuéllar
T9 EDAD_SUJETO_ASISTENCIA 389 396 70 años
T10 ID_TITULACION_PERSONAL_SANITARIO 334 345 28 28 70973
T11 FECHAS 282 292 12/12/2016
T12 SEXO_SUJETO_ASISTENCIA 261 262 H
T13 EDAD_SUJETO_ASISTENCIA 247 254 70 años
T14 PAIS 233 239 España
T15 TERRITORIO 166 171 28016
T16 TERRITORIO 154 160 Madrid
T17 CALLE 107 130 Calle Miguel Benitez 90
T18 ID_ASEGURAMIENTO 82 93 26 63514095
T19 ID_SUJETO_ASISTENCIA 68 74 368503
T20 NOMBRE_SUJETO_ASISTENCIA 49 61 Rivera Bueno
T21 NOMBRE_SUJETO_ASISTENCIA 29 36 Ernesto
Ahora que tenemos una idea más clara de nuestros datos originales, observamos la serie de pre-procesos aplicados por el meddocan_pipeline
y GsDocs
gracias a la instancia gold
del objeto Doc
para preparar el conjunto de datos a fin de entrenar una red neuronal con Flair.
Note
En el ejemplo solo miramos las 3 primeras lineas del objeto Doc
.
max_lines = 3
for i, sent in enumerate(gold.sents):
print(f"--------------- Sentence {i + 1} ---------------")
a = zip(*((tok.text, tok.ent_iob_, tok.ent_type_) for tok in sent))
df = pd.DataFrame(a, index=["text", "bio", "etiqueta"])
display(df.T)
if i >= max_lines - 1:
break
--------------- Sentence 1 ---------------
text | bio | etiqueta | |
---|---|---|---|
0 | Datos | O | |
1 | del | O | |
2 | paciente | O | |
3 | . | O | |
4 | \n | O |
--------------- Sentence 2 ---------------
text | bio | etiqueta | |
---|---|---|---|
0 | Nombre | O | |
1 | : | O | |
2 | O | ||
3 | Ernesto | B | NOMBRE_SUJETO_ASISTENCIA |
4 | . | O | |
5 | \n | O |
--------------- Sentence 3 ---------------
text | bio | etiqueta | |
---|---|---|---|
0 | Apellidos | O | |
1 | : | O | |
2 | Rivera | B | NOMBRE_SUJETO_ASISTENCIA |
3 | Bueno | I | NOMBRE_SUJETO_ASISTENCIA |
4 | . | O | |
5 | \n | O |
Para entender un poco mejor lo que hacemos miramos los diferentes componentes del MeddocanPipeline
.
pd.DataFrame(gs_docs.nlp.pipe_names, columns=["componentes"]).T
0 | 1 | 2 | 3 | |
---|---|---|---|---|
componentes | missaligned_splitter | line_sentencizer | predictor | write_methods |
El primer elemento de nuestro pipeline es el tokenizer seguido del componente
missaligned_splitter
que nos permite afinar la tokenización de tal forma que cada token se corresponda exactamente con una etiqueta al formato BIO.El segundo componente,
line_sentencizer
permite partir el texto en frases. En este caso se corresponde a un párrafo.El componente
predictor
nos permite utilizar un modelo de Flair de tal forma que se integra al pipeline. De esa mañera se puede hacer directamente predicciones utilizando un objetoDoc
y un modelo entrenado previamente.El componente
write_methods
añade los métodosto_connl03
yto_ann
al objetoDoc
que sirven a crear los ficheros necesarios para:Crear un
flair.data.Corpus
que va a permitir entrenar un modelo con Flair.Evaluar un modelo utilizando el script de evaluación propio de la competición.
Note
Hemos integrado el script de evaluación dentro de nuestra librería con algunas modificaciones y un poco más de documentación, con el objetivo de unificar el workflow del entrenamiento hasta la evaluación.
La evaluación se hace entonces directamente desde nuestra librería gracias al commando:
$ meddocan eval --help
Usage: meddocan eval [OPTIONS] MODEL NAME
Evaluate the model with the `meddocan` metrics.
Compute f1-score for Ner (start, end, tag), Span (start, end) and merged
span if not there is no number or letter between consecutive span.
The function produce the following temporary folder hierarchy:
evaluation_root
├── golds
│ ├── dev
| | └── brat
| | ├── file-1.ann
| | ├── file-1.txt
| | ├── ...
| | └── file-n.ann
| └── test
| └── brat
| ├── file-1.ann
| ├── file-1.txt
| ├── ...
| └── file-n.ann
│
└── name
├── dev
| └── brat
| ├── file-1.ann
| ├── file-1.txt
| ├── ...
| └── file-n.ann
└── test
└── brat
├── file-1.ann
├── file-1.txt
├── ...
└── file-n.ann
Then the model is evaluate producing the following files:
evaluation_root/name
├── dev
│ ├── ner
│ └── spans
└── test
├── ner
└── spans
And the temporary folder are removed.
Args:
model (str): Path to the Flair model to evaluate.
name (str): Name of the folder that will holds the results produced by\
the Flair model.
evaluation_root (str): Path to the root folder where the
results will be stored.
sentence_splitting (Path): Path to the sub-directory
`sentence_splitting`. This directory is mandatory to compute the
`leak score` evaluation metric.
force (bool, optional): Force to create again the golds standard files.
Defaults to False.
Arguments:
MODEL Path to the Flair model to evaluate. [required]
NAME Name of the folder that will holds the results produced by the
Flair model. [required]
Options:
--evaluation-root PATH Path to the root folder where the results will be
stored.
--sentence-splitting PATH The sub-directory `sentence_splitting` is
mandatory to compute the `leak score` evaluation
metric.
--device TEXT Device to use. [default: cuda:0]
--help Show this message and exit.
La entidades anotadas se obtienen utilizando el atributo ents
de nuestro objeto spacy.tokens.Doc
.
pd.DataFrame(
zip(*[(ent.text, ent.start_char, ent.end_char) for ent in gold.ents]),
index=["Tag", "start", "end"]
).T
Tag | start | end | |
---|---|---|---|
0 | Ernesto | 29 | 36 |
1 | Rivera Bueno | 49 | 61 |
2 | 368503 | 68 | 74 |
3 | 26 63514095 | 82 | 93 |
4 | Calle Miguel Benitez 90 | 107 | 130 |
5 | Madrid | 154 | 160 |
6 | 28016 | 166 | 171 |
7 | 03/03/1946 | 215 | 225 |
8 | España | 233 | 239 |
9 | 70 años | 247 | 254 |
10 | H | 261 | 262 |
11 | 12/12/2016 | 282 | 292 |
12 | Ignacio Navarro Cuéllar | 303 | 326 |
13 | 28 28 70973 | 334 | 345 |
14 | 70 años | 389 | 396 |
15 | Ignacio Navarro Cuéllar | 2341 | 2364 |
16 | c/ del Abedul 5-7, 2º dcha | 2365 | 2391 |
17 | 28036 | 2392 | 2397 |
18 | Madrid | 2398 | 2404 |
19 | España | 2406 | 2412 |
20 | nnavcu@hotmail.com | 2421 | 2439 |
Nuestras entidades son en este ejemplo compuestas del tag y del a position de la entidad en la cadena de caracteres original.
Ahora si queremos algo más visual podemos utilizar la function spacy.displacy.render()
que nos permite trabajar con el objeto Doc
.
displacy.render(gold, style="ent")
5.3.2. Entrenar un modelo con Flair#
La clase GsDocs
sirve a preparar los datos de forma que puedan ser leídos por Flair. Esto lo hacemos a través del objeto meddocan.data.corpus.MEDDOCAN
que hereda de flair.datasets.ColumnCorpus
.
Este corpus permite a la biblioteca Flair acceder directamente a los conjuntos de datos de entrenamiento, validación y prueba.
from meddocan.data.corpus import MEDDOCAN
corpus = MEDDOCAN(sentences=True, in_memory=True, document_separator_token="-DOCSTART-")
2022-11-08 11:12:52,616 Reading data from /tmp/tmp7uu7l1fq
2022-11-08 11:12:52,617 Train: /tmp/tmp7uu7l1fq/train
2022-11-08 11:12:52,618 Dev: /tmp/tmp7uu7l1fq/dev
2022-11-08 11:12:52,618 Test: /tmp/tmp7uu7l1fq/test
Una vez creado nuestro corpus podemos utilizar los métodos de los que hereda nuestro objeto como el método especial __str__
que escribe en stdout el número de objetos flair.tokens.Sentence
que contiene cada subconjunto del conjunto de datos. Es decir, cuántos párrafos tenemos en total en cada uno de nuestros conjuntos de datos.
print(corpus)
Corpus: 10811 train + 5518 dev + 5405 test sentences
Para entrenar los modelos con la librería Flair basta seguir el ejemplo siguiente.
Note
Todos nuestros experimentos se pueden encontrar en la carpeta experiments y siguen este formato.
La única diferencia es la adición de las librerías hyperopt 1 y Tensorboard 2 para obtener una trazabilidad de los entrenamientos mediante el registro de diversos parámetros y resultados.
from flair.data import Corpus
from flair.embeddings import TransformerWordEmbeddings
from flair.models import SequenceTagger
from flair.trainers import ModelTrainer
from meddocan.data.corpus import MEDDOCAN
# 1. Obtener el corpus
corpus: Corpus = MEDDOCAN(
sentences=True, document_separator_token="-DOCSTART-"
)
print(corpus)
# 2. Que label se quiere predecir?
label_type = 'ner'
# 3. Crear el diccionario de labels a partir del corpus
label_dict = corpus.make_label_dictionary(label_type=label_type, add_unk=False)
print(label_dict)
# 4. Inicializar los embeddings generados por el transformador utilizando el contexto
embeddings = TransformerWordEmbeddings(model='dccuchile/bert-base-spanish-wwm-cased',
layers="-1",
subtoken_pooling="first",
fine_tune=True,
use_context=True,
)
# 5. Inicializar etiquedator simple (no CRF, no RNN, no reprojección)
tagger = SequenceTagger(hidden_size=256,
embeddings=embeddings,
tag_dictionary=label_dict,
tag_type='ner',
use_crf=False,
use_rnn=False,
reproject_embeddings=False,
)
# 6. Initializar el trainer
trainer = ModelTrainer(tagger, corpus)
# 7. Ejecutar el fine-tuning
trainer.fine_tune('experiments/meddocan',
learning_rate=5.0e-6,
mini_batch_size=4,
)
5.3.3. Inferencia#
Una vez el modelo entrenado, la inferencia se hace gracias al meddocan_pipeline
justamente porque nos permite integrar un modelo de Flair
gracias a su componente predictor
como lo vamos a ver a continuación.
Note
Aquí utilizamos por el ejemplo un modelo de FLair pre-entrenado con los embeddings de Flair y una red LSTM-CRF y entrenado sobre el conjunto de datos CONLL-2002 en inglés. Este conjunto de datos consiste en artículos de noticias anotados con las categorías LOC, PER y ORG que son diferentes de las categorías de MEDDOCAN.
from meddocan.language.pipeline import meddocan_pipeline
nlp = meddocan_pipeline("flair/ner-english-fast")
sys = nlp(gold.text)
2022-11-08 11:13:00,076 loading file /home/wave/.meddocan/models/ner-english-fast/4c58e7191ff952c030b82db25b3694b58800b0e722ff15427f527e1631ed6142.e13c7c4664ffe2bbfa8f1f5375bd0dced866b8c1dd7ff89a6d705518abf0a611
2022-11-08 11:13:02,946 SequenceTagger predicts: Dictionary with 20 tags: <unk>, O, S-ORG, S-MISC, B-PER, E-PER, S-LOC, B-ORG, E-ORG, I-PER, S-PER, B-MISC, I-MISC, E-MISC, I-ORG, B-LOC, E-LOC, I-LOC, <START>, <STOP>
El objeto sys
es un objeto spacy.tokens.Doc
al igual de el objeto gold
.
La única diferencia entre sys
y gold
es que sys
contiene entidades que le son asignadas por un modelo entrenado con los algoritmos de Flair, mientras que en el caso de gold
provienen de la lectura de un archivo .ann.
Entonces para visualizar las predicciones de nuestro model, lo tenemos igual de fácil que antes. Basta utilizar la function spacy.displacy.render()
.
displacy.render(sys, style="ent")
Note
Vemos a ojo que el modelo de Flair flair/ner-english-fast
parece detectar correctamente el span de ciertas entidades. Les asigna una etiqueta como LOC
o PERS
y efectivamente son direcciones o personas aunque por supuesto no tiene el mismo etiquetado.
5.3.4. Evaluation#
La evaluación originalmente provistas a través del script de evaluación que re-utilizamos, utiliza el texto original así como su anotación al formato brat para calcular las métricas según las tareas Subtrack1
, Subtrack2 [Strict]
y SubTrack2 [Merged]
.
Para evaluar nuestros modelos, utilizamos el texto original a partir del cual se ha creado el documento sys
así como su atributo _.to_ann
. Ese método permite codificar el atributo ents
del objeto sys
en un fichero siguiendo el formado brat como se puede ver a continuación.
from tempfile import TemporaryDirectory
with TemporaryDirectory() as td:
pth = Path(td, "file.txt")
sys._.to_ann(pth)
for i, line in enumerate(pth.read_text().split("\n")):
print(line)
T_0 ORG 0 18 Datos del paciente
T_1 PER 29 36 Ernesto
T_2 PER 49 61 Rivera Bueno
T_3 ORG 76 80 NASS
T_4 PER 107 127 Calle Miguel Benitez
T_5 LOC 132 141 Localidad
T_6 LOC 143 152 Provincia
T_7 LOC 154 160 Madrid
T_8 ORG 162 164 CP
T_9 MISC 194 213 Fecha de nacimiento
T_10 ORG 233 239 España
T_11 ORG 241 245 Edad
T_12 ORG 264 280 Fecha de Ingreso
T_13 ORG 294 300 Médico
T_14 ORG 303 332 Ignacio Navarro Cuéllar NºCol
T_15 MISC 377 404 Paciente de 70 años de edad
T_16 MISC 1758 1767 En la UIV
T_17 PER 2341 2364 Ignacio Navarro Cuéllar
T_18 PER 2368 2378 del Abedul
T_19 LOC 2398 2404 Madrid
T_20 ORG 2406 2414 España E
Para hacer esto más automatizado, al igual que la clase meddocan.data.docs_iterators.GsDocs
, tenemos la clase from meddocan.data.docs_iterators.SysDocs
que utiliza un modelo de Flair para detectar entidades sobre los documentos de cada sub-conjunto de datos. Veamos un ejemplo:
from meddocan.data.docs_iterators import SysDocs
from meddocan.data import ArchiveFolder
import torch
import flair
flair.device = torch.device("cuda:1")
sys_docs = iter(SysDocs(archive_name=ArchiveFolder.test, model="flair/ner-spanish-large"))
2022-11-08 11:13:05,538 loading file /home/wave/.meddocan/models/ner-spanish-large/045ad6c7dc21e0eb85935dce0544eec65f8c63c58412154df4dee7ff5f11665b.d4d3456316d2951bc100d060bd63a690b33af6d273adffa1b90df32328ed3257
2022-11-08 11:13:34,905 SequenceTagger predicts: Dictionary with 20 tags: <unk>, O, S-LOC, S-ORG, B-PER, I-PER, E-PER, S-MISC, B-ORG, E-ORG, S-PER, I-ORG, B-LOC, E-LOC, B-MISC, E-MISC, I-MISC, I-LOC, <START>, <STOP>
Gracias a las clase GsDocs
y SysDocs
podemos producir fácilmente los ficheros requeridos para usar el script de evaluación provisto a través de la la linea de comando meddocan eval.
Como curiosidad podemos explicar como se pueden calcular las métricas.
gs_docs = iter(GsDocs(archive_name=ArchiveFolder.test))
Recuperamos los iteradores sys_doc
y gold_doc
y comprobamos que corresponden a los mismos documentos originales, tanto en su origen como en su contenido.
sys_doc, gold_doc = next(sys_docs), next(gs_docs)
assert sys_doc.brat_files_pair.ann.name == gold_doc.brat_files_pair.ann.name
assert sys_doc.brat_files_pair.txt.name == gold_doc.brat_files_pair.txt.name
assert sys_doc.doc.text == gold_doc.doc.text
Ahora recuperamos las entidades originales en gold_labels
y las predicciones sys_labels
.
from meddocan.evaluation.classes import Ner, Span
gold_labels = set(Ner(ent.start, ent.end, ent.label_) for ent in gold.ents)
sys_labels = set(Ner(ent.start, ent.end, ent.label_) for ent in sys.ents)
Por fin calculamos el score \(F_{1} micro\) para el documento así como el recall y la precision.
from typing import TypeVar
T = TypeVar("T", Ner, Span)
def f1(gold_label: List[T], sys_label: List[T]) -> float:
tp = gold_label.intersection(sys_label)
fp = sys_label - gold_label
fn = gold_label - sys_label
try:
recall = len(tp) / float(len(tp) + len(fp))
except ZeroDivisionError:
recall = 0.0
try:
precision = len(tp) / float(len(tp) + len(fn))
except ZeroDivisionError:
precision = 0.0
try:
f1 = 2 * (recall * precision) / (recall + precision)
except ZeroDivisionError:
f1 = 0.0
return f1, recall, precision
pd.DataFrame(
f1(gold_labels, sys_labels),
index=["f1", "recall", "precisión"],
columns=["Subtrack1"]
).T
f1 | recall | precisión | |
---|---|---|---|
Subtrack1 | 0.0 | 0.0 | 0.0 |
¡El score \(F_{1} micro\) esta nulo simplemente porque el modelo utilizado no predice las mismas entidades y ademas esta entrenado sobre un conjunto de datos en inglés! Pero si se trata únicamente de anonimizar y que usamos solo los Span
puede que no sea lo mismo dado que ahora no importan las etiquetas sino solo la posición de las entidades.
gold_spans = set(Span(ent.start, ent.end) for ent in gold.ents)
sys_spans = set(Span(ent.start, ent.end) for ent in sys.ents)
pd.DataFrame(
f1(gold_spans, sys_spans),
index=["f1", "recall", "precision"],
columns=["Subtrack2[strict]"]
).T
f1 | recall | precision | |
---|---|---|---|
Subtrack2[strict] | 0.285714 | 0.285714 | 0.285714 |
De hecho, vemos que incluso con un modelo entrenado en un conjunto de datos muy diferente con tan sólo 4 entidades, conseguimos anonimizar un poco más de un cuartos de los span deseados.