clinical-nlp-api / src /db /models.py
Ayodeji Akande
Pin ruff config and fix the resulting lint findings
4dc0836
Raw
History Blame Contribute Delete
12.4 kB
"""
src/db/models.py
────────────────────────────────────────────────────────────────
SQLAlchemy ORM models β€” one class per database table.
Table overview
──────────────
clinical_notes : processed MTSamples / MIMIC-III notes
entities : NER extractions (one row per entity span)
icd10_mappings : entity β†’ ICD-10 code matches
model_runs : audit trail for classifier training runs
Design decisions
────────────────
- All primary keys are auto-incrementing integers. This is
simpler than UUIDs and sufficient for this scale.
- created_at is set by the database server, not the application,
so it is consistent regardless of timezone or clock skew.
- Text columns use Text() not String(N) β€” clinical notes can
be arbitrarily long and we do not want silent truncation.
- Relationships are defined with lazy="select" (the default)
so they do not cause N+1 query problems in the API routes.
Callers that need related data should use joinedload() or
subqueryload() explicitly.
- Every table has an updated_at column maintained by an
onupdate trigger so we can audit changes without extra code.
────────────────────────────────────────────────────────────────
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
func,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
"""Shared declarative base for all ORM models.
Every table inherits from this so SQLAlchemy can discover
them all when create_all_tables() is called.
"""
class ClinicalNote(Base):
"""A single processed clinical note.
Populated by the ETL pipeline from MTSamples or MIMIC-III.
One row per unique transcription.
Columns
───────
source_id : stable identifier from the source dataset
(e.g. "mtsamples_42"). Used to detect
duplicate loads on re-runs.
transcription : cleaned note text
specialty : medical specialty (e.g. "Cardiology")
note_type : document type (e.g. "Discharge Summary")
severity : weak-supervision label β€” routine / urgent / critical
word_count : pre-computed for fast filtering
data_source : which dataset this came from (mtsamples / mimic3)
"""
__tablename__ = "clinical_notes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source_id: Mapped[str] = mapped_column(
String(120), unique=True, nullable=False, index=True,
comment="Stable ID from the source dataset β€” prevents duplicate loads",
)
transcription: Mapped[str] = mapped_column(
Text, nullable=False,
comment="Cleaned clinical note text",
)
specialty: Mapped[str | None] = mapped_column(
String(120), nullable=True, index=True,
comment="Medical specialty (e.g. Cardiology, Neurology)",
)
note_type: Mapped[str | None] = mapped_column(
String(120), nullable=True,
comment="Document type (e.g. Discharge Summary, Consult Note)",
)
severity: Mapped[str | None] = mapped_column(
String(20), nullable=True, index=True,
comment="Weak-supervision severity label: routine / urgent / critical",
)
word_count: Mapped[int | None] = mapped_column(
Integer, nullable=True,
comment="Pre-computed word count for fast length filtering",
)
data_source: Mapped[str] = mapped_column(
String(30), nullable=False, default="mtsamples",
comment="Source dataset identifier (mtsamples or mimic3)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(),
onupdate=func.now(), nullable=False,
)
# A note can have many extracted entities
entities: Mapped[list[Entity]] = relationship(
"Entity", back_populates="note", cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return (
f"ClinicalNote(id={self.id}, specialty={self.specialty!r}, "
f"severity={self.severity!r}, words={self.word_count})"
)
class Entity(Base):
"""A single named entity extracted from a clinical note.
One row per entity span. A note with ten extracted entities
produces ten rows in this table.
Columns
───────
note_id : foreign key to clinical_notes
text : the exact extracted text (e.g. "hypertension")
label : entity type from the NER model
(DISEASE, MEDICATION, PROCEDURE, SYMPTOM, ANATOMY)
start_char : character offset in the cleaned transcription
end_char : character offset end
confidence : model confidence score (0.0 – 1.0) where available
"""
__tablename__ = "entities"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
note_id: Mapped[int] = mapped_column(
Integer, ForeignKey("clinical_notes.id", ondelete="CASCADE"),
nullable=False, index=True,
)
text: Mapped[str] = mapped_column(
String(500), nullable=False,
comment="Extracted entity text as it appears in the note",
)
label: Mapped[str] = mapped_column(
String(50), nullable=False, index=True,
comment="Entity type: DISEASE, MEDICATION, PROCEDURE, SYMPTOM, ANATOMY",
)
start_char: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
comment="Start character offset in the transcription",
)
end_char: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
comment="End character offset in the transcription",
)
confidence: Mapped[float | None] = mapped_column(
Float, nullable=True,
comment="NER model confidence score (0.0–1.0)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False,
)
# Back-reference to the parent note
note: Mapped[ClinicalNote] = relationship(
"ClinicalNote", back_populates="entities",
)
# An entity can have multiple ICD-10 candidate mappings
icd10_mappings: Mapped[list[ICD10Mapping]] = relationship(
"ICD10Mapping", back_populates="entity", cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return (
f"Entity(id={self.id}, text={self.text!r}, "
f"label={self.label!r}, conf={self.confidence:.2f})"
if self.confidence is not None
else f"Entity(id={self.id}, text={self.text!r}, label={self.label!r})"
)
class ICD10Mapping(Base):
"""An ICD-10 code matched to an extracted entity.
One entity can have multiple candidate codes (top-k results).
The best match is the one with the highest confidence.
Columns
───────
entity_id : foreign key to entities
icd10_code : ICD-10-CM code (e.g. "I10", "J18.9")
description : human-readable description of the code
match_method : how the match was made β€” "lookup" (fuzzy string
match) or "embedding" (cosine similarity fallback)
confidence : match confidence (0.0 – 1.0)
rank : position in the top-k results (1 = best match)
"""
__tablename__ = "icd10_mappings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
entity_id: Mapped[int] = mapped_column(
Integer, ForeignKey("entities.id", ondelete="CASCADE"),
nullable=False, index=True,
)
icd10_code: Mapped[str] = mapped_column(
String(20), nullable=False, index=True,
comment="ICD-10-CM diagnostic code",
)
description: Mapped[str] = mapped_column(
String(500), nullable=False, default="",
comment="Human-readable ICD-10 code description",
)
match_method: Mapped[str] = mapped_column(
String(20), nullable=False, default="lookup",
comment="How the match was found: lookup or embedding",
)
confidence: Mapped[float] = mapped_column(
Float, nullable=False, default=0.0,
comment="Match confidence score (0.0–1.0)",
)
rank: Mapped[int] = mapped_column(
Integer, nullable=False, default=1,
comment="Position in top-k candidates (1 = best match)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False,
)
# Back-reference to the parent entity
entity: Mapped[Entity] = relationship(
"Entity", back_populates="icd10_mappings",
)
def __repr__(self) -> str:
return (
f"ICD10Mapping(code={self.icd10_code!r}, "
f"method={self.match_method!r}, conf={self.confidence:.2f})"
)
class ModelRun(Base):
"""Audit record for a classifier training run.
Tracks which model was trained, when, on how many samples,
and what performance metrics it achieved. Useful for
comparing successive fine-tuning runs.
Columns
───────
model_name : HuggingFace model ID used as base
task : classification task (severity / readmission)
training_samples : number of training examples
val_accuracy : validation accuracy at end of training
val_f1 : weighted F1 score on validation set
epochs : number of training epochs completed
notes : free-text notes about the run
"""
__tablename__ = "model_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
model_name: Mapped[str] = mapped_column(
String(200), nullable=False,
comment="Base model used for fine-tuning",
)
task: Mapped[str] = mapped_column(
String(50), nullable=False, index=True,
comment="Classification task: severity or readmission",
)
training_samples: Mapped[int | None] = mapped_column(
Integer, nullable=True,
)
val_accuracy: Mapped[float | None] = mapped_column(
Float, nullable=True,
comment="Validation accuracy (0.0–1.0)",
)
val_f1: Mapped[float | None] = mapped_column(
Float, nullable=True,
comment="Weighted F1 score on validation set",
)
test_accuracy: Mapped[float | None] = mapped_column(
Float, nullable=True,
comment="Held-out test set accuracy (0.0-1.0)",
)
test_f1: Mapped[float | None] = mapped_column(
Float, nullable=True,
comment="Weighted F1 score on the held-out test set",
)
per_class: Mapped[dict | None] = mapped_column(
JSON, nullable=True,
comment="Per-class {label: {precision, recall, f1, support}} on the test set",
)
confusion_matrix: Mapped[list | None] = mapped_column(
JSON, nullable=True,
comment="Test-set confusion matrix, rows=actual/cols=predicted, class order matches ClassifierConfig labels",
)
history: Mapped[list | None] = mapped_column(
JSON, nullable=True,
comment="Per-epoch [{epoch, loss, accuracy, f1}] validation history",
)
epochs: Mapped[int | None] = mapped_column(
Integer, nullable=True,
)
is_deployed: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
comment="Whether this run is the currently deployed model",
)
run_notes: Mapped[str | None] = mapped_column(
Text, nullable=True,
comment="Free-text notes about training conditions or observations",
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False,
)
def __repr__(self) -> str:
return (
f"ModelRun(id={self.id}, task={self.task!r}, "
f"val_f1={self.val_f1}, deployed={self.is_deployed})"
)