BacterialIdentifierShowcase / phenotype_inference.py
EphAsad's picture
Upload 9 files
2baf26e verified
Raw
History Blame Contribute Delete
18.3 kB
import re
import math
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
DEFAULT_SCHEMA = [
"Gram Stain",
"Shape",
"Catalase",
"Oxidase",
"Colony Morphology",
"Haemolysis",
"Haemolysis Type",
"Indole",
"Growth Temperature",
"Media Grown On",
"Motility",
"Motility Type",
"Capsule",
"Spore Formation",
"Oxygen Requirement",
"Methyl Red",
"VP",
"Citrate",
"Urease",
"H2S",
"Lactose Fermentation",
"Glucose Fermentation",
"Sucrose Fermentation",
"Nitrate Reduction",
"Lysine Decarboxylase",
"Ornithine Decarboxylase",
"Arginine dihydrolase",
"Gelatin Hydrolysis",
"Esculin Hydrolysis",
"DNase",
"ONPG",
"NaCl Tolerant (>=6%)",
"Lipase Test",
"Xylose Fermentation",
"Rhamnose Fermentation",
"Mannitol Fermentation",
"Sorbitol Fermentation",
"Maltose Fermentation",
"Arabinose Fermentation",
"Raffinose Fermentation",
"Inositol Fermentation",
"Trehalose Fermentation",
"Coagulase",
"TSI Pattern",
"Gas Production",
]
class PhenotypeTinyTransformer(nn.Module):
def __init__(
self,
vocab_size,
num_classes,
aux_dim,
max_tokens,
d_model=512,
n_heads=8,
n_layers=8,
ff_dim=1536,
dropout=0.12,
):
super().__init__()
self.token_embedding = nn.Embedding(vocab_size, d_model, padding_idx=0)
self.position_embedding = nn.Embedding(max_tokens, d_model)
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model,
nhead=n_heads,
dim_feedforward=ff_dim,
dropout=dropout,
activation="gelu",
batch_first=True,
norm_first=True,
)
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=n_layers)
self.aux_mlp = nn.Sequential(
nn.Linear(aux_dim, d_model),
nn.GELU(),
nn.LayerNorm(d_model),
nn.Dropout(dropout),
)
self.classifier = nn.Sequential(
nn.LayerNorm(d_model * 2),
nn.Dropout(dropout),
nn.Linear(d_model * 2, d_model),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(d_model, num_classes),
)
def forward(self, input_ids, attention_mask, aux):
batch_size, seq_len = input_ids.shape
positions = torch.arange(seq_len, device=input_ids.device)
positions = positions.unsqueeze(0).expand(batch_size, seq_len)
x = self.token_embedding(input_ids) + self.position_embedding(positions)
key_padding_mask = attention_mask == 0
encoded = self.encoder(x, src_key_padding_mask=key_padding_mask)
cls_vec = encoded[:, 0, :]
aux_vec = self.aux_mlp(aux)
combined = torch.cat([cls_vec, aux_vec], dim=-1)
return self.classifier(combined)
class PhenotypeClassifier:
def __init__(self, model_path, device=None):
self.model_path = Path(model_path)
if not self.model_path.exists():
raise FileNotFoundError(f"Model file not found: {self.model_path}")
self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
try:
checkpoint = torch.load(self.model_path, map_location=self.device, weights_only=False)
except TypeError:
checkpoint = torch.load(self.model_path, map_location=self.device)
self.config = checkpoint["config"]
self.vocab = checkpoint["vocab"]
self.classes = checkpoint["classes"]
temperature_info = checkpoint.get("temperature_scaling", {})
self.temperature = float(temperature_info.get("temperature", 1.0))
self.schema = self.config.get("schema", DEFAULT_SCHEMA)
self.semicolon_fields = set(self.config.get("semicolon_fields", [
"Shape",
"Colony Morphology",
"Media Grown On",
"Motility Type",
"Oxygen Requirement",
]))
self.core_fields = self.config.get("core_fields", [
"Gram Stain",
"Shape",
"Catalase",
"Oxidase",
"Oxygen Requirement",
"Spore Formation",
"Motility",
"Indole",
"Urease",
"Citrate",
"H2S",
"Nitrate Reduction",
])
self.growth_temp_thresholds = self.config.get(
"growth_temperature_thresholds",
[4, 10, 20, 25, 30, 37, 42, 45, 50, 55],
)
self.max_tokens = int(self.config.get("max_tokens", 192))
self.aux_dim = int(self.config.get("aux_dim", 10))
self.d_model = int(self.config.get("d_model", 512))
self.n_heads = int(self.config.get("n_heads", 8))
self.n_layers = int(self.config.get("n_layers", 8))
self.ff_dim = int(self.config.get("ff_dim", 1536))
self.dropout = float(self.config.get("dropout", 0.12))
self.pad_token = "<PAD>"
self.unk_token = "<UNK>"
self.cls_token = "<CLS>"
self.field_lookup = {field.lower(): field for field in self.schema}
self.use_amp = self.device.type == "cuda"
self.use_bf16 = self.device.type == "cuda" and torch.cuda.is_bf16_supported()
self.amp_dtype = torch.bfloat16 if self.use_bf16 else torch.float16
self.model = PhenotypeTinyTransformer(
vocab_size=len(self.vocab),
num_classes=len(self.classes),
aux_dim=self.aux_dim,
max_tokens=self.max_tokens,
d_model=self.d_model,
n_heads=self.n_heads,
n_layers=self.n_layers,
ff_dim=self.ff_dim,
dropout=self.dropout,
).to(self.device)
self.model.load_state_dict(checkpoint["model_state_dict"])
self.model.eval()
@staticmethod
def safe_str(x):
if x is None:
return None
if isinstance(x, float) and np.isnan(x):
return None
x = str(x).strip()
return x if x else None
@classmethod
def split_value(cls, value):
value = cls.safe_str(value)
if value is None:
return []
if ";" in value:
return [p.strip() for p in value.split(";") if p.strip()]
return [value]
def canonical_field_name(self, field):
field = self.safe_str(field)
if field is None:
return None
lowered = field.lower()
if lowered in self.field_lookup:
return self.field_lookup[lowered]
return field
@staticmethod
def normalize_basic_value(value):
value = PhenotypeClassifier.safe_str(value)
if value is None:
return None
lowered = value.lower()
value_map = {
"pos": "Positive",
"positive": "Positive",
"+": "Positive",
"neg": "Negative",
"negative": "Negative",
"-": "Negative",
"var": "Variable",
"variable": "Variable",
"none": "None",
"n/a": "None",
"na": "None",
"aerobe": "Aerobic",
"aerobic": "Aerobic",
"anaerobe": "Anaerobic",
"anaerobic": "Anaerobic",
"facultative anaerobe": "Facultative Anaerobic",
"facultative anaerobic": "Facultative Anaerobic",
"microaerophile": "Microaerophilic",
"microaerophilic": "Microaerophilic",
"rod": "Rods",
"rods": "Rods",
"short rods": "Short Rods",
"short rod": "Short Rods",
"cocci": "Cocci",
"coccus": "Cocci",
"yeast": "Yeast",
"spiral": "Spiral",
}
return value_map.get(lowered, value)
def normalize_features(self, input_features):
if not isinstance(input_features, dict):
raise TypeError("input_features must be a dictionary.")
normalized = {}
unknown_fields = []
for raw_field, raw_value in input_features.items():
field = self.canonical_field_name(raw_field)
if field not in self.schema:
unknown_fields.append(raw_field)
continue
if isinstance(raw_value, list):
parts = [self.normalize_basic_value(v) for v in raw_value]
parts = [p for p in parts if p is not None]
value = "; ".join(parts)
else:
parts = self.split_value(raw_value)
parts = [self.normalize_basic_value(p) for p in parts]
parts = [p for p in parts if p is not None]
value = "; ".join(parts)
if value:
normalized[field] = value
return normalized, unknown_fields
def parse_growth_temperature(self, value):
raw = self.safe_str(value)
if raw is None:
return None, None, "missing"
text = raw.strip()
if text.lower() in {"unknown", "none", "variable", "var", "not known", "n/a", "na"}:
return None, None, "non_numeric"
if "//" in text:
left, right = text.split("//", 1)
left_nums = re.findall(r"-?\d+(?:\.\d+)?", left)
right_nums = re.findall(r"-?\d+(?:\.\d+)?", right)
if left_nums and right_nums:
return float(left_nums[0]), float(right_nums[0]), "parsed_double_slash"
range_patterns = [
r"(-?\d+(?:\.\d+)?)\s*[-–—]\s*(-?\d+(?:\.\d+)?)",
r"(-?\d+(?:\.\d+)?)\s*/\s*(-?\d+(?:\.\d+)?)",
r"(-?\d+(?:\.\d+)?)\s+to\s+(-?\d+(?:\.\d+)?)",
]
for pattern in range_patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
low = float(match.group(1))
high = float(match.group(2))
return low, high, "parsed_range"
nums = re.findall(r"-?\d+(?:\.\d+)?", text)
if len(nums) >= 2:
vals = [float(x) for x in nums]
return min(vals), max(vals), "parsed_multiple_numbers_minmax"
if len(nums) == 1:
val = float(nums[0])
return val, val, "single_number"
return None, None, "parse_failed"
def build_tokens_and_aux(self, features):
tokens = []
observed_count = 0
core_observed_count = 0
growth_low = 0.0
growth_high = 0.0
growth_range = 0.0
growth_mid = 0.0
growth_numeric_present = 0.0
grows_at_37 = 0.0
grows_45_plus = 0.0
grows_10_or_below = 0.0
for field in self.schema:
value = self.safe_str(features.get(field))
if value is None:
continue
value_tokens = self.split_value(value)
if not value_tokens:
continue
observed_count += 1
if field in self.core_fields:
core_observed_count += 1
tokens.append(f"FIELD::{field}")
for token in value_tokens:
tokens.append(f"{field}={token}")
if field in self.semicolon_fields:
tokens.append(f"FIELD_TOKEN_COUNT::{field}={len(value_tokens)}")
if field == "Growth Temperature":
low, high, status = self.parse_growth_temperature(value)
tokens.append(f"GROWTH_TEMP_PARSE_STATUS={status}")
if low is not None and high is not None:
if high < low:
low, high = high, low
growth_numeric_present = 1.0
growth_low = float(low)
growth_high = float(high)
growth_range = float(high - low)
growth_mid = float((low + high) / 2.0)
low_bin = int(math.floor(growth_low / 5.0) * 5)
high_bin = int(math.floor(growth_high / 5.0) * 5)
mid_bin = int(math.floor(growth_mid / 5.0) * 5)
tokens.append(f"GROWTH_TEMP_LOW_BIN={low_bin}")
tokens.append(f"GROWTH_TEMP_HIGH_BIN={high_bin}")
tokens.append(f"GROWTH_TEMP_MIDPOINT_BIN={mid_bin}")
for threshold in self.growth_temp_thresholds:
if growth_low <= threshold <= growth_high:
tokens.append(f"GROWS_AT_{threshold}C=YES")
else:
tokens.append(f"GROWS_AT_{threshold}C=NO")
grows_at_37 = 1.0 if growth_low <= 37 <= growth_high else 0.0
grows_45_plus = 1.0 if growth_high >= 45 else 0.0
grows_10_or_below = 1.0 if growth_low <= 10 else 0.0
aux = np.array([
observed_count / len(self.schema),
core_observed_count / len(self.core_fields),
growth_numeric_present,
growth_low / 80.0,
growth_high / 80.0,
growth_range / 80.0,
growth_mid / 80.0,
grows_at_37,
grows_45_plus,
grows_10_or_below,
], dtype=np.float32)
return tokens, aux
def encode_tokens(self, tokens):
ids = [self.vocab[self.cls_token]]
unknown_tokens = []
for token in tokens:
token_id = self.vocab.get(token)
if token_id is None:
token_id = self.vocab[self.unk_token]
unknown_tokens.append(token)
ids.append(token_id)
ids = ids[:self.max_tokens]
attention_mask = [1] * len(ids)
while len(ids) < self.max_tokens:
ids.append(self.vocab[self.pad_token])
attention_mask.append(0)
return (
np.array(ids, dtype=np.int64),
np.array(attention_mask, dtype=np.int64),
unknown_tokens,
)
@staticmethod
def confidence_bucket(probability, margin):
if probability >= 0.95 and margin >= 0.25:
return "High"
if probability >= 0.80 and margin >= 0.15:
return "Medium-high"
if probability >= 0.50 and margin >= 0.08:
return "Medium"
if probability >= 0.30:
return "Low-medium"
return "Low"
@staticmethod
def distinctness_bucket(margin):
if margin >= 0.30:
return "Very distinct"
if margin >= 0.15:
return "Fairly distinct"
if margin >= 0.07:
return "Ambiguous"
return "Very ambiguous"
@torch.no_grad()
def predict(self, input_features, top_k=10):
normalized_features, unknown_fields = self.normalize_features(input_features)
tokens, aux = self.build_tokens_and_aux(normalized_features)
if len(tokens) == 0:
raise ValueError("No valid phenotype fields were provided.")
input_ids_np, attention_mask_np, unknown_tokens = self.encode_tokens(tokens)
input_ids = torch.tensor(input_ids_np, dtype=torch.long).unsqueeze(0).to(self.device)
attention_mask = torch.tensor(attention_mask_np, dtype=torch.long).unsqueeze(0).to(self.device)
aux_tensor = torch.tensor(aux, dtype=torch.float32).unsqueeze(0).to(self.device)
self.model.eval()
with torch.autocast(
device_type=self.device.type,
dtype=self.amp_dtype,
enabled=self.use_amp,
):
logits = self.model(input_ids, attention_mask, aux_tensor)
logits = logits.float()
calibrated_logits = logits / self.temperature
probs = torch.softmax(calibrated_logits, dim=-1).squeeze(0).cpu().numpy()
order = np.argsort(probs)[::-1]
top_indices = order[:top_k]
ranked = []
for rank, idx in enumerate(top_indices, start=1):
ranked.append({
"rank": rank,
"genus": self.classes[int(idx)],
"probability": float(probs[int(idx)]),
})
top1_prob = float(probs[order[0]])
top2_prob = float(probs[order[1]]) if len(order) > 1 else 0.0
margin = top1_prob - top2_prob
provided_fields = list(normalized_features.keys())
missing_fields = [field for field in self.schema if field not in normalized_features]
return {
"top_genus": self.classes[int(order[0])],
"top_probability": top1_prob,
"top2_probability": top2_prob,
"margin": margin,
"confidence": self.confidence_bucket(top1_prob, margin),
"distinctness": self.distinctness_bucket(margin),
"temperature": self.temperature,
"ranked_genera": ranked,
"provided_fields": provided_fields,
"missing_fields": missing_fields,
"unknown_input_fields_ignored": unknown_fields,
"unknown_model_tokens": unknown_tokens,
"num_unknown_model_tokens": len(unknown_tokens),
"num_provided_fields": len(provided_fields),
"num_model_tokens": len(tokens),
"note": "This is a phenotype-based genus prediction, not a confirmed laboratory identification.",
}
def print_prediction(result):
print()
print("=" * 60)
print("PHENOTYPECLASSIFIER PREDICTION")
print("=" * 60)
print(f"Top genus: {result['top_genus']}")
print(f"Probability: {result['top_probability']:.4f}")
print(f"Margin: {result['margin']:.4f}")
print(f"Confidence: {result['confidence']}")
print(f"Distinctness: {result['distinctness']}")
print(f"Fields used: {result['num_provided_fields']}")
print(f"Model tokens: {result['num_model_tokens']}")
print(f"Unknown tokens: {result['num_unknown_model_tokens']}")
print("-" * 60)
print("Top ranked genera:")
for item in result["ranked_genera"]:
print(f"{item['rank']:>2}. {item['genus']:<25} {item['probability']:.4f}")
print("-" * 60)
print(result["note"])
print("=" * 60)