phytoevidence-api / numeric_core.py
shazhass's picture
fix(numeric): distinguish publication counts in v4.2.17
071019d
Raw
History Blame Contribute Delete
18.5 kB
"""Pure deterministic claim/evidence number matching."""
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class Num:
kind: str
op: str
lo: float
hi: float | None
unit: str | None
_UNIT_NORM = {
"mg": 1.0, "g": 1000.0, "ug": 1e-3, "mcg": 1e-3,
"ml": 1.0, "l": 1000.0, "iu": 1.0, "ui": 1.0,
"mmol": 1.0, "nmol": 1e-3, "umol": 1e-6,
}
_DAY_NORM = {
"day": 1, "days": 1, "jour": 1, "jours": 1,
"week": 7, "weeks": 7, "semaine": 7, "semaines": 7,
"month": 30, "months": 30, "mois": 30,
}
_WORD_NUMBER_NORM = {
"one": 1, "two": 2, "three": 3, "four": 4,
"five": 5, "six": 6, "seven": 7, "eight": 8,
"nine": 9, "ten": 10, "eleven": 11, "twelve": 12,
"un": 1, "une": 1, "deux": 2, "trois": 3,
"quatre": 4, "cinq": 5, "six": 6, "sept": 7,
"huit": 8, "neuf": 9, "dix": 10, "onze": 11,
"douze": 12,
}
_NUMBER = (
r"-?(?:\d+(?:[.,]\d+)?|[.,]\d+)"
r"(?:[eE][+-]?\d+)?"
)
_STUDY_COUNT_SAFE_UNITS = {
"one": 1,
"two": 2,
"three": 3,
"four": 4,
"five": 5,
"six": 6,
"seven": 7,
"eight": 8,
"nine": 9,
}
_STUDY_COUNT_SAFE_DIRECT = {
**_STUDY_COUNT_SAFE_UNITS,
"ten": 10,
"eleven": 11,
"twelve": 12,
"thirteen": 13,
"fourteen": 14,
"fifteen": 15,
"sixteen": 16,
"seventeen": 17,
"eighteen": 18,
"nineteen": 19,
"un": 1,
"une": 1,
"deux": 2,
"trois": 3,
"quatre": 4,
"cinq": 5,
"six": 6,
"sept": 7,
"huit": 8,
"neuf": 9,
"dix": 10,
"onze": 11,
"douze": 12,
}
_STUDY_COUNT_SAFE_TENS = {
"twenty": 20,
"thirty": 30,
"forty": 40,
"fifty": 50,
"sixty": 60,
"seventy": 70,
"eighty": 80,
"ninety": 90,
}
_STUDY_COUNT_SAFE_WORD_TOKEN = (
r"(?:"
r"one|two|three|four|five|six|seven|eight|nine|"
r"ten|eleven|twelve|thirteen|fourteen|fifteen|"
r"sixteen|seventeen|eighteen|nineteen|"
r"(?:twenty|thirty|forty|fifty|sixty|seventy|"
r"eighty|ninety)"
r"(?:[\s-]+(?:one|two|three|four|five|six|"
r"seven|eight|nine))?|"
r"un|une|deux|trois|quatre|cinq|six|sept|"
r"huit|neuf|dix|onze|douze"
r")"
)
# Liste fermée : les mots arbitraires comme "hundred",
# "analyses", "comparisons", "across", "of" ou "year"
# ne peuvent pas être consommés comme qualificatifs.
_STUDY_COUNT_SAFE_QUALIFIER = (
r"(?:"
r"animal|human|clinical|preclinical|experimental|"
r"randomized|randomised|controlled|observational|"
r"prospective|retrospective|eligible|included|"
r"published|previous|independent|comparative|"
r"animales?|animaux|humaines?|cliniques?|"
r"precliniques?|précliniques?|experimentales?|"
r"expérimentales?|randomisees?|randomisées?|"
r"controlees?|contrôlées?|observationnelles?|"
r"prospectives?|retrospectives?|rétrospectives?|"
r"eligibles?|éligibles?|incluses?|publiees?|"
r"publiées?|anterieures?|antérieures?|"
r"independantes?|indépendantes?|comparatives?"
r")"
)
def _parse_safe_study_count_word(
value: str,
) -> float:
normalized = re.sub(
r"[\s-]+",
" ",
str(value or "").strip().casefold(),
)
if normalized in _STUDY_COUNT_SAFE_DIRECT:
return float(
_STUDY_COUNT_SAFE_DIRECT[normalized]
)
parts = normalized.split()
if (
len(parts) == 1
and parts[0] in _STUDY_COUNT_SAFE_TENS
):
return float(
_STUDY_COUNT_SAFE_TENS[parts[0]]
)
if (
len(parts) == 2
and parts[0] in _STUDY_COUNT_SAFE_TENS
and parts[1] in _STUDY_COUNT_SAFE_UNITS
):
return float(
_STUDY_COUNT_SAFE_TENS[parts[0]]
+ _STUDY_COUNT_SAFE_UNITS[parts[1]]
)
raise ValueError(
f"Unsupported safe study count: {value!r}"
)
_PATTS = [
(
"p",
re.compile(
rf"\bp\s*([<>=]=?)\s*({_NUMBER})",
re.I,
),
),
(
"ci",
re.compile(
rf"(?:(?:95\s*%?\s*(?:CI|IC))|"
rf"(?:(?:CI|IC)\s*95\s*%?))"
rf"\s*[,;:=]?\s*[\[\(]?\s*"
rf"({_NUMBER})\s*%?"
rf"\s*(?:[-\u2013]|\bto\b|\bà\b|,\s+)\s*"
rf"({_NUMBER})\s*%?",
re.I,
),
),
(
"dose",
re.compile(
rf"({_NUMBER})\s*"
r"(mg|g|ug|mcg|IU|UI|ml|l|mmol|nmol|umol)\b",
re.I,
),
),
(
"percent",
re.compile(
rf"({_NUMBER})\s*%(?!\s*(?:CI|IC))",
re.I,
),
),
(
"n",
re.compile(
r"\bn\s*=\s*(\d[\d ,]*)",
re.I,
),
),
(
"sample",
re.compile(
r"\b(\d[\d ,]*)\s+"
r"(?:(?:[A-Za-zÀ-ÿ-]+)\s+){0,2}"
r"(?:participants?|patients?|subjects?|students?|"
r"volunteers?|adults?|children|individuals?|women|men|"
r"étudiants?|etudiants?|sujets?|volontaires?|adultes?|"
r"enfants?|femmes?|hommes?)\b",
re.I,
),
),
(
"publication_count_word_safe",
re.compile(
rf"(?<![A-Za-zÀ-ÿ-])"
rf"({_STUDY_COUNT_SAFE_WORD_TOKEN})"
rf"(?:\s+{_STUDY_COUNT_SAFE_QUALIFIER}){{0,2}}"
r"\s+"
r"(?:papers?|articles?|publications?)\b",
re.I,
),
),
(
"publication_count",
re.compile(
r"\b(\d+)\s+"
r"(?:papers?|articles?|publications?)\b",
re.I,
),
),
(
"study_count_word_safe",
re.compile(
rf"(?<![A-Za-zÀ-ÿ-])"
rf"({_STUDY_COUNT_SAFE_WORD_TOKEN})"
rf"(?:\s+{_STUDY_COUNT_SAFE_QUALIFIER}){{0,2}}"
r"\s+"
r"(?:studies|trials|experiments|études|etudes|"
r"essais|expériences|experiences)\b",
re.I,
),
),
(
"study_count",
re.compile(
r"\b(\d+)\s+"
r"(?:studies|trials|experiments|études|etudes|"
r"essais|expériences|experiences)\b",
re.I,
),
),
(
"duration_word",
re.compile(
r"\b(one|two|three|four|five|six|seven|eight|nine|"
r"ten|eleven|twelve|un|une|deux|trois|quatre|cinq|"
r"six|sept|huit|neuf|dix|onze|douze)"
r"(?:\s+|-)\s*"
r"(weeks?|days?|months?|semaines?|jours?|mois)\b",
re.I,
),
),
(
"duration",
re.compile(
rf"({_NUMBER})\s*"
r"(weeks?|days?|months?|semaines?|jours?|mois)\b",
re.I,
),
),
(
"effect_size",
re.compile(
rf"\b(?:SMD|MD|"
rf"standardized\s+mean\s+difference|"
rf"standardised\s+mean\s+difference|"
rf"mean\s+difference)\b"
rf"\s*(?:[,;:=]|\bis\b|\bwas\b|\bof\b)?"
rf"\s*({_NUMBER})",
re.I,
),
),
(
"ratio",
re.compile(
rf"\b(?:OR|RR|HR|"
rf"hazard\s+ratio|odds\s+ratio|"
rf"risk\s+ratio|relative\s+risk)\b"
rf"\s*(?:[,;:=]|\bis\b|\bwas\b|\bof\b)?"
rf"\s*({_NUMBER})",
re.I,
),
),
]
_GENERIC_NUMBER = re.compile(
rf"(?<![\w.,])({_NUMBER})(?![\w]|[.,]\d)",
re.I,
)
_EFFECT_SIZE_CUE = re.compile(
r"\b(?:"
r"effect\s+sizes?|"
r"effect\s+estimates?|"
r"standardized\s+mean\s+differences?|"
r"standardised\s+mean\s+differences?|"
r"hedges(?:['’]s)?\s+g|"
r"mean\s+g|"
r"SMD|MD|OR|RR|HR|"
r"tailles?\s+d['’]effet|"
r"estimations?\s+de\s+l['’]effet"
r")\b",
re.I,
)
_PARENTHETICAL_CI_AFTER_ESTIMATE = re.compile(
rf"(?P<estimate>{_NUMBER})\s*"
rf"(?P<interval>[\[(]\s*"
rf"(?P<lo>{_NUMBER})\s*,\s*"
rf"(?P<hi>{_NUMBER})\s*[\])])",
re.I,
)
_SENTENCE_BOUNDARY = re.compile(
r"(?<!\d)[.!?](?!\d)|\n"
)
def _to_float(value: str) -> float:
return float(
str(value).strip().replace(",", ".")
)
def _overlaps(
span: tuple[int, int],
occupied: list[tuple[int, int]],
) -> bool:
start, end = span
return any(
start < previous_end
and previous_start < end
for previous_start, previous_end
in occupied
)
_STUDY_COUNT_ELLIPSIS_RE = re.compile(
r"(?P<prefix>\bfrom\s+(?:the\s+)?"
r"(?:\d+|[A-Za-z]+(?:[- ][A-Za-z]+)?)\s+"
r"(?:included\s+)?"
r"(?:papers?|stud(?:y|ies)|trials?|experiments?)"
r"\s*,\s*)"
r"(?P<count>\d+)"
r"(?P<suffix>\s+with\s+\d+\s+comparisons?\s+"
r"were\s+meta[- ]analy[sz]ed\b)",
re.I,
)
def _expand_elliptical_study_counts(
text: str,
) -> str:
"""Expand only strongly anchored study-count ellipses."""
return _STUDY_COUNT_ELLIPSIS_RE.sub(
lambda match: (
f"{match.group('prefix')}"
f"{match.group('count')} studies"
f"{match.group('suffix')}"
),
str(text or ""),
)
def extract(text: str) -> list[Num]:
text = _expand_elliptical_study_counts(str(text or ""))
source = str(text or "")
found = []
occupied = []
# Certains abstracts rapportent une taille d'effet suivie
# directement de ses bornes, sans écrire explicitement
# "95% CI", par exemple: 1.19 (0.74, 1.64).
#
# La paire parenthétique n'est classée comme intervalle
# que si la même phrase contient un marqueur explicite
# de taille/estimation d'effet et si l'estimation est
# comprise entre les deux bornes. Le span occupé exclut
# l'estimation, afin qu'elle reste extraite comme scalaire
# ou ratio par les règles existantes.
for match in _PARENTHETICAL_CI_AFTER_ESTIMATE.finditer(
source
):
context_start = 0
for boundary in _SENTENCE_BOUNDARY.finditer(
source,
0,
match.start("estimate"),
):
context_start = boundary.end()
context = source[
context_start:match.start("estimate")
]
if not _EFFECT_SIZE_CUE.search(context):
continue
try:
estimate = _to_float(
match.group("estimate")
)
lo = _to_float(match.group("lo"))
hi = _to_float(match.group("hi"))
except ValueError:
continue
if not (
lo < hi
and lo <= estimate <= hi
):
continue
span = match.span("interval")
if _overlaps(span, occupied):
continue
found.append(
(
span,
Num(
"ci",
"=",
lo,
hi,
None,
),
)
)
occupied.append(span)
for kind, rx in _PATTS:
for match in rx.finditer(source):
groups = match.groups()
try:
if kind == "p":
number = Num(
"p",
groups[0],
_to_float(groups[1]),
None,
None,
)
elif kind == "ci":
number = Num(
"ci",
"=",
_to_float(groups[0]),
_to_float(groups[1]),
None,
)
elif kind == "dose":
unit = groups[1].lower()
number = Num(
"dose",
"=",
_to_float(groups[0])
* _UNIT_NORM.get(unit, 1.0),
None,
unit,
)
elif kind == "percent":
number = Num(
"percent",
"=",
_to_float(groups[0]),
None,
None,
)
elif kind in {"n", "sample"}:
value = (
groups[0]
.replace(",", "")
.replace(" ", "")
)
number = Num(
"n",
"=",
float(value),
None,
None,
)
elif kind == "publication_count_word_safe":
number = Num(
"publication_count",
"=",
_parse_safe_study_count_word(
groups[0]
),
None,
None,
)
elif kind == "publication_count":
number = Num(
"publication_count",
"=",
float(groups[0]),
None,
None,
)
elif kind == "study_count_word_safe":
number = Num(
"study_count",
"=",
_parse_safe_study_count_word(
groups[0]
),
None,
None,
)
elif kind == "study_count":
number = Num(
"study_count",
"=",
float(groups[0]),
None,
None,
)
elif kind == "duration_word":
number = Num(
"duration",
"=",
float(
_WORD_NUMBER_NORM[
groups[0].lower()
]
)
* _DAY_NORM.get(
groups[1].lower(),
1.0,
),
None,
"days",
)
elif kind == "duration":
number = Num(
"duration",
"=",
_to_float(groups[0])
* _DAY_NORM.get(
groups[1].lower(),
1.0,
),
None,
"days",
)
elif kind in {
"effect_size",
"ratio",
}:
number = Num(
kind,
"=",
_to_float(groups[0]),
None,
None,
)
else:
continue
except (ValueError, IndexError):
continue
span = match.span()
if _overlaps(span, occupied):
continue
found.append((span, number))
occupied.append(span)
for match in _GENERIC_NUMBER.finditer(
source
):
span = match.span()
if _overlaps(span, occupied):
continue
try:
value = _to_float(
match.group(1)
)
except ValueError:
continue
found.append(
(
span,
Num(
"scalar",
"=",
value,
None,
None,
),
)
)
occupied.append(span)
found.sort(
key=lambda item: item[0][0]
)
return [
number
for _, number in found
]
def _match_one(c: Num, ev: list[Num], rel_tol: float = 0.02) -> str:
# "scalar" est le type historique des estimations
# additives non libellées dans les abstracts.
# "effect_size" est le type explicite de SMD/MD.
#
# Ces deux représentations additives sont compatibles,
# mais restent strictement séparées des vrais ratios
# OR/RR/HR, des pourcentages, p-values et intervalles.
compatible_kinds = (
{"scalar", "effect_size"}
if c.kind in {"scalar", "effect_size"}
else {c.kind}
)
cands = [
e
for e in ev
if e.kind in compatible_kinds
]
if not cands:
return "UNSUPPORTED"
for e in cands:
if c.kind == "p":
if abs(e.lo - c.lo) <= 1e-9:
return "MATCH"
elif c.kind == "ci":
if (c.hi is not None and e.hi is not None
and abs(e.lo - c.lo) <= rel_tol * max(abs(c.lo), 1e-9)
and abs(e.hi - c.hi) <= rel_tol * max(abs(c.hi), 1e-9)):
return "MATCH"
else:
if abs(e.lo - c.lo) <= rel_tol * max(abs(c.lo), 1e-9):
return "MATCH"
return "MISMATCH"
def validate_numeric(
claim_en: str,
evidence_en: str,
rel_tol: float = 0.02,
) -> dict:
claim_text = str(claim_en or "")
evidence_text = str(evidence_en or "")
c_nums = extract(claim_text)
e_nums = extract(evidence_text)
digit_present = bool(
re.search(r"\d", claim_text)
)
results = [
{
"kind": c.kind,
"value": c.lo,
"verdict": _match_one(
c,
e_nums,
rel_tol,
),
}
for c in c_nums
]
unparsed_numeric = (
digit_present
and not c_nums
)
numeric_ok = (
all(
result["verdict"] == "MATCH"
for result in results
)
if c_nums
else not digit_present
)
return {
"numeric_ok": numeric_ok,
"has_numbers": bool(c_nums),
"unparsed_numeric":
unparsed_numeric,
"detail": results,
}