Spaces:
Runtime error
Runtime error
uzzam2121 commited on
Commit ·
fc7db98
1
Parent(s): 5353eed
Fix build: bundle code in Space repo
Browse files- Dockerfile +9 -9
- src/deploy/backend/app/__init__.py +0 -0
- src/deploy/backend/app/config.py +39 -0
- src/deploy/backend/app/main.py +135 -0
- src/deploy/backend/app/schemas.py +79 -0
- src/deploy/backend/app/services/__init__.py +0 -0
- src/deploy/backend/app/services/efficiency.py +27 -0
- src/deploy/backend/app/services/interventions.py +38 -0
- src/deploy/backend/app/services/model.py +58 -0
- src/deploy/backend/requirements.txt +11 -0
- src/deploy/huggingface/download_model.py +61 -0
- src/deploy/huggingface/entrypoint.sh +5 -0
- src/linguistic_features.py +83 -0
- src/model_loading.py +54 -0
- src/predictor.py +200 -0
- src/reason_interventions.py +132 -0
- src/syntax_complexity.py +163 -0
- src/two_head_model.py +231 -0
- src/ui/__init__.py +0 -0
- src/ui/efficiency_panel.py +69 -0
- src/utils.py +135 -0
Dockerfile
CHANGED
|
@@ -1,22 +1,22 @@
|
|
| 1 |
-
# Hugging Face Space —
|
| 2 |
-
#
|
| 3 |
FROM python:3.11-slim
|
| 4 |
|
| 5 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
-
build-essential
|
| 7 |
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
|
| 9 |
RUN useradd -m -u 1000 user
|
| 10 |
|
| 11 |
-
WORKDIR /app
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
RUN pip install --no-cache-dir -r /app/repo/deploy/backend/requirements.txt huggingface_hub \
|
| 16 |
&& python -m spacy download en_core_web_sm
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
| 20 |
|
| 21 |
ENV MODEL_PATH=/app/model
|
| 22 |
ENV HF_MODEL_REPO=uzzam24/deberta-complexity-lcp
|
|
|
|
| 1 |
+
# Hugging Face Space — all code copied into Space repo (no GitHub clone needed).
|
| 2 |
+
# Run sync_to_space.ps1 from project root, then git push the Space repo.
|
| 3 |
FROM python:3.11-slim
|
| 4 |
|
| 5 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
build-essential \
|
| 7 |
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
|
| 9 |
RUN useradd -m -u 1000 user
|
| 10 |
|
| 11 |
+
WORKDIR /app/repo
|
| 12 |
|
| 13 |
+
COPY src/deploy/backend/requirements.txt /tmp/requirements.txt
|
| 14 |
+
RUN pip install --no-cache-dir -r /tmp/requirements.txt huggingface_hub \
|
|
|
|
| 15 |
&& python -m spacy download en_core_web_sm
|
| 16 |
|
| 17 |
+
COPY --chown=user:user src/ /app/repo/
|
| 18 |
+
|
| 19 |
+
RUN chmod +x /app/repo/deploy/huggingface/entrypoint.sh
|
| 20 |
|
| 21 |
ENV MODEL_PATH=/app/model
|
| 22 |
ENV HF_MODEL_REPO=uzzam24/deberta-complexity-lcp
|
src/deploy/backend/app/__init__.py
ADDED
|
File without changes
|
src/deploy/backend/app/config.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backend configuration — model path and CORS."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# Repo root (Complexity_level/) when running locally or in Docker /app
|
| 9 |
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
| 10 |
+
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def resolve_model_path() -> Path:
|
| 14 |
+
"""MODEL_PATH env, then overnight_bundle, then exported_models."""
|
| 15 |
+
if env := os.environ.get("MODEL_PATH"):
|
| 16 |
+
return Path(env).expanduser().resolve()
|
| 17 |
+
|
| 18 |
+
candidates = [
|
| 19 |
+
REPO_ROOT / "model",
|
| 20 |
+
Path("/app/model"),
|
| 21 |
+
REPO_ROOT / "overnight_bundle" / "exported_models" / "deberta_best",
|
| 22 |
+
REPO_ROOT / "exported_models" / "deberta_best",
|
| 23 |
+
Path("/app/overnight_bundle/exported_models/deberta_best"),
|
| 24 |
+
Path("/app/exported_models/deberta_best"),
|
| 25 |
+
]
|
| 26 |
+
for path in candidates:
|
| 27 |
+
if (path / "model_weights.pt").exists():
|
| 28 |
+
return path
|
| 29 |
+
# Return first candidate for error messaging even if weights missing
|
| 30 |
+
return candidates[0]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
MODEL_PATH = resolve_model_path()
|
| 34 |
+
CORS_ORIGINS = [
|
| 35 |
+
o.strip()
|
| 36 |
+
for o in os.environ.get("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
|
| 37 |
+
if o.strip()
|
| 38 |
+
]
|
| 39 |
+
PORT = int(os.environ.get("PORT", "8000"))
|
src/deploy/backend/app/main.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI backend for Railway — word complexity inference."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI, HTTPException
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
|
| 11 |
+
from app.config import CORS_ORIGINS, MODEL_PATH
|
| 12 |
+
from app.schemas import (
|
| 13 |
+
EfficiencyRequest,
|
| 14 |
+
EfficiencyResponse,
|
| 15 |
+
HealthResponse,
|
| 16 |
+
InterveneAllRequest,
|
| 17 |
+
InterveneAllResponse,
|
| 18 |
+
InterveneRequest,
|
| 19 |
+
InterveneResponse,
|
| 20 |
+
PredictRequest,
|
| 21 |
+
PredictResponse,
|
| 22 |
+
)
|
| 23 |
+
from app.services.efficiency import compare_efficiency
|
| 24 |
+
from app.services.interventions import run_intervention
|
| 25 |
+
from app.services.model import get_predictor, model_status, predict
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@asynccontextmanager
|
| 29 |
+
async def lifespan(_app: FastAPI):
|
| 30 |
+
if os.environ.get("EAGER_LOAD_MODEL", "1") == "1":
|
| 31 |
+
try:
|
| 32 |
+
get_predictor()
|
| 33 |
+
except RuntimeError as exc:
|
| 34 |
+
print(f"WARN: model not loaded at startup: {exc}")
|
| 35 |
+
yield
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
app = FastAPI(title="Word Complexity API", version="1.0.0", lifespan=lifespan)
|
| 39 |
+
|
| 40 |
+
app.add_middleware(
|
| 41 |
+
CORSMiddleware,
|
| 42 |
+
allow_origins=["*"] if os.environ.get("CORS_ALLOW_ALL") == "1" else CORS_ORIGINS,
|
| 43 |
+
allow_credentials=os.environ.get("CORS_ALLOW_ALL") != "1",
|
| 44 |
+
allow_methods=["*"],
|
| 45 |
+
allow_headers=["*"],
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@app.get("/")
|
| 50 |
+
def root():
|
| 51 |
+
ready, err = model_status()
|
| 52 |
+
return {
|
| 53 |
+
"service": "Word Complexity API",
|
| 54 |
+
"health": "/health",
|
| 55 |
+
"docs": "/docs",
|
| 56 |
+
"model_ready": ready,
|
| 57 |
+
"model_error": err,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@app.get("/health", response_model=HealthResponse)
|
| 62 |
+
def health():
|
| 63 |
+
ready, err = model_status()
|
| 64 |
+
return HealthResponse(
|
| 65 |
+
status="ok" if ready else "degraded",
|
| 66 |
+
model_path=str(MODEL_PATH),
|
| 67 |
+
model_ready=ready,
|
| 68 |
+
model_error=err,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@app.post("/api/predict", response_model=PredictResponse)
|
| 73 |
+
def api_predict(body: PredictRequest):
|
| 74 |
+
try:
|
| 75 |
+
data, latency_ms, target_in = predict(body.sentence.strip(), body.target_word.strip())
|
| 76 |
+
except RuntimeError as exc:
|
| 77 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 78 |
+
return PredictResponse(**data, latency_ms=latency_ms, target_in_sentence=target_in)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@app.post("/api/intervene", response_model=InterveneResponse)
|
| 82 |
+
def api_intervene(body: InterveneRequest):
|
| 83 |
+
try:
|
| 84 |
+
predictor = get_predictor()
|
| 85 |
+
detail = run_intervention(
|
| 86 |
+
predictor,
|
| 87 |
+
body.sentence.strip(),
|
| 88 |
+
body.target_word.strip(),
|
| 89 |
+
body.reason,
|
| 90 |
+
body.use_llm,
|
| 91 |
+
)
|
| 92 |
+
except RuntimeError as exc:
|
| 93 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 94 |
+
except ValueError as exc:
|
| 95 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 96 |
+
return InterveneResponse(intervention=detail)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@app.post("/api/intervene/all", response_model=InterveneAllResponse)
|
| 100 |
+
def api_intervene_all(body: InterveneAllRequest):
|
| 101 |
+
try:
|
| 102 |
+
predictor = get_predictor()
|
| 103 |
+
base = predictor.predict(body.sentence.strip(), body.target_word.strip())
|
| 104 |
+
from utils import REASON_ORDER
|
| 105 |
+
|
| 106 |
+
results = [
|
| 107 |
+
run_intervention(
|
| 108 |
+
predictor,
|
| 109 |
+
body.sentence.strip(),
|
| 110 |
+
body.target_word.strip(),
|
| 111 |
+
reason,
|
| 112 |
+
body.use_llm,
|
| 113 |
+
predicted_reason=body.predicted_reason,
|
| 114 |
+
)
|
| 115 |
+
for reason in REASON_ORDER
|
| 116 |
+
]
|
| 117 |
+
except RuntimeError as exc:
|
| 118 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 119 |
+
|
| 120 |
+
best = max(results, key=lambda r: r.hardness_drop)
|
| 121 |
+
faithful = None
|
| 122 |
+
if body.predicted_reason:
|
| 123 |
+
faithful = best.reason == body.predicted_reason
|
| 124 |
+
|
| 125 |
+
return InterveneAllResponse(
|
| 126 |
+
base_level=base.complexity_level,
|
| 127 |
+
results=results,
|
| 128 |
+
best_reason=best.reason,
|
| 129 |
+
faithful=faithful,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@app.post("/api/efficiency", response_model=EfficiencyResponse)
|
| 134 |
+
def api_efficiency(body: EfficiencyRequest):
|
| 135 |
+
return compare_efficiency(body.sentence.strip(), body.target_word.strip(), body.local_latency_ms)
|
src/deploy/backend/app/schemas.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class PredictRequest(BaseModel):
|
| 7 |
+
sentence: str = Field(..., min_length=1)
|
| 8 |
+
target_word: str = Field(..., min_length=1)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class PredictResponse(BaseModel):
|
| 12 |
+
complexity_level: str
|
| 13 |
+
level_id: int
|
| 14 |
+
level_probs: dict[str, float]
|
| 15 |
+
difficult_class_prob: float
|
| 16 |
+
reason: str | None
|
| 17 |
+
reason_id: int | None
|
| 18 |
+
reason_probs: dict[str, float] | None
|
| 19 |
+
latency_ms: float
|
| 20 |
+
target_in_sentence: bool
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class InterveneRequest(BaseModel):
|
| 24 |
+
sentence: str
|
| 25 |
+
target_word: str
|
| 26 |
+
reason: str
|
| 27 |
+
use_llm: bool = False
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class InterventionDetail(BaseModel):
|
| 31 |
+
reason: str
|
| 32 |
+
original_sentence: str
|
| 33 |
+
edited_sentence: str
|
| 34 |
+
edit_method: str
|
| 35 |
+
before_level: str
|
| 36 |
+
after_level: str
|
| 37 |
+
hardness_drop: float
|
| 38 |
+
matches_predicted: bool | None = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class InterveneResponse(BaseModel):
|
| 42 |
+
intervention: InterventionDetail
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class InterveneAllRequest(BaseModel):
|
| 46 |
+
sentence: str
|
| 47 |
+
target_word: str
|
| 48 |
+
predicted_reason: str | None = None
|
| 49 |
+
use_llm: bool = False
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class InterveneAllResponse(BaseModel):
|
| 53 |
+
base_level: str
|
| 54 |
+
results: list[InterventionDetail]
|
| 55 |
+
best_reason: str
|
| 56 |
+
faithful: bool | None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class EfficiencyRequest(BaseModel):
|
| 60 |
+
sentence: str
|
| 61 |
+
target_word: str
|
| 62 |
+
local_latency_ms: float = Field(..., ge=0)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class EfficiencyResponse(BaseModel):
|
| 66 |
+
local_latency_ms: float
|
| 67 |
+
ai_latency_ms: float
|
| 68 |
+
ai_provider: str
|
| 69 |
+
ai_cost_usd: float
|
| 70 |
+
ai_cost_per_1k_usd: float
|
| 71 |
+
local_cost_usd: float
|
| 72 |
+
speedup_factor: float
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class HealthResponse(BaseModel):
|
| 76 |
+
status: str
|
| 77 |
+
model_path: str
|
| 78 |
+
model_ready: bool
|
| 79 |
+
model_error: str | None = None
|
src/deploy/backend/app/services/__init__.py
ADDED
|
File without changes
|
src/deploy/backend/app/services/efficiency.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
from app.config import REPO_ROOT
|
| 6 |
+
from app.schemas import EfficiencyResponse
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _ensure_path() -> None:
|
| 10 |
+
if str(REPO_ROOT) not in sys.path:
|
| 11 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def compare_efficiency(sentence: str, target_word: str, local_latency_ms: float) -> EfficiencyResponse:
|
| 15 |
+
_ensure_path()
|
| 16 |
+
from ui.efficiency_panel import compare_query
|
| 17 |
+
|
| 18 |
+
cmp = compare_query(sentence, target_word, local_latency_ms)
|
| 19 |
+
return EfficiencyResponse(
|
| 20 |
+
local_latency_ms=cmp.local_latency_ms,
|
| 21 |
+
ai_latency_ms=cmp.ai_latency_ms,
|
| 22 |
+
ai_provider=cmp.ai_provider,
|
| 23 |
+
ai_cost_usd=cmp.ai_cost_usd,
|
| 24 |
+
ai_cost_per_1k_usd=cmp.ai_cost_per_1k_usd,
|
| 25 |
+
local_cost_usd=cmp.local_cost_usd,
|
| 26 |
+
speedup_factor=cmp.speedup_factor,
|
| 27 |
+
)
|
src/deploy/backend/app/services/interventions.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
from app.config import REPO_ROOT
|
| 6 |
+
from app.schemas import InterventionDetail
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _ensure_path() -> None:
|
| 10 |
+
if str(REPO_ROOT) not in sys.path:
|
| 11 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def run_intervention(
|
| 15 |
+
predictor,
|
| 16 |
+
sentence: str,
|
| 17 |
+
target_word: str,
|
| 18 |
+
reason: str,
|
| 19 |
+
use_llm: bool,
|
| 20 |
+
predicted_reason: str | None = None,
|
| 21 |
+
) -> InterventionDetail:
|
| 22 |
+
_ensure_path()
|
| 23 |
+
from reason_interventions import apply_intervention
|
| 24 |
+
|
| 25 |
+
base = predictor.predict(sentence, target_word)
|
| 26 |
+
intervention = apply_intervention(reason, sentence, target_word, use_llm=use_llm)
|
| 27 |
+
after = predictor.predict(intervention.edited_sentence, target_word)
|
| 28 |
+
drop = base.difficult_class_prob - after.difficult_class_prob
|
| 29 |
+
return InterventionDetail(
|
| 30 |
+
reason=reason,
|
| 31 |
+
original_sentence=intervention.original_sentence,
|
| 32 |
+
edited_sentence=intervention.edited_sentence,
|
| 33 |
+
edit_method=intervention.edit_method,
|
| 34 |
+
before_level=base.complexity_level,
|
| 35 |
+
after_level=after.complexity_level,
|
| 36 |
+
hardness_drop=round(drop, 4),
|
| 37 |
+
matches_predicted=reason == predicted_reason if predicted_reason else None,
|
| 38 |
+
)
|
src/deploy/backend/app/services/model.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lazy-load ComplexityPredictor from repo inference code."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
import time
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from app.config import MODEL_PATH, REPO_ROOT
|
| 10 |
+
|
| 11 |
+
_predictor = None
|
| 12 |
+
_load_error: str | None = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _ensure_inference_path() -> None:
|
| 16 |
+
if str(REPO_ROOT) not in sys.path:
|
| 17 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_predictor():
|
| 21 |
+
global _predictor, _load_error
|
| 22 |
+
if _predictor is not None:
|
| 23 |
+
return _predictor
|
| 24 |
+
if _load_error is not None:
|
| 25 |
+
raise RuntimeError(_load_error)
|
| 26 |
+
|
| 27 |
+
_ensure_inference_path()
|
| 28 |
+
weights = MODEL_PATH / "model_weights.pt"
|
| 29 |
+
if not weights.exists():
|
| 30 |
+
_load_error = (
|
| 31 |
+
f"Model weights not found at {weights}. "
|
| 32 |
+
"Set MODEL_PATH or place deberta_best under overnight_bundle/exported_models/."
|
| 33 |
+
)
|
| 34 |
+
raise RuntimeError(_load_error)
|
| 35 |
+
|
| 36 |
+
from predictor import ComplexityPredictor
|
| 37 |
+
|
| 38 |
+
t0 = time.perf_counter()
|
| 39 |
+
_predictor = ComplexityPredictor.load(MODEL_PATH)
|
| 40 |
+
print(f"Model loaded from {MODEL_PATH} in {(time.perf_counter() - t0):.1f}s")
|
| 41 |
+
return _predictor
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def model_status() -> tuple[bool, str | None]:
|
| 45 |
+
try:
|
| 46 |
+
get_predictor()
|
| 47 |
+
return True, None
|
| 48 |
+
except Exception as exc:
|
| 49 |
+
return False, str(exc)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def predict(sentence: str, target_word: str) -> tuple[dict, float, bool]:
|
| 53 |
+
predictor = get_predictor()
|
| 54 |
+
t0 = time.perf_counter()
|
| 55 |
+
result = predictor.predict(sentence, target_word)
|
| 56 |
+
latency_ms = (time.perf_counter() - t0) * 1000
|
| 57 |
+
target_in = target_word in sentence
|
| 58 |
+
return result.to_dict(), latency_ms, target_in
|
src/deploy/backend/requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110.0
|
| 2 |
+
uvicorn[standard]>=0.27.0
|
| 3 |
+
python-multipart>=0.0.9
|
| 4 |
+
torch>=2.0.0
|
| 5 |
+
transformers>=4.36.0
|
| 6 |
+
numpy>=1.24.0
|
| 7 |
+
sentencepiece>=0.1.99
|
| 8 |
+
spacy>=3.7.0
|
| 9 |
+
wordfreq>=3.0.0
|
| 10 |
+
nltk>=3.8.0
|
| 11 |
+
openai>=1.0.0
|
src/deploy/huggingface/download_model.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download exported model from Hugging Face Hub before API startup.
|
| 3 |
+
|
| 4 |
+
Set Space secret / env:
|
| 5 |
+
HF_MODEL_REPO=your-username/deberta-complexity-lcp
|
| 6 |
+
MODEL_PATH=/app/model
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main() -> None:
|
| 17 |
+
repo = os.environ.get("HF_MODEL_REPO", "").strip()
|
| 18 |
+
target = Path(os.environ.get("MODEL_PATH", "/app/model"))
|
| 19 |
+
|
| 20 |
+
if not repo:
|
| 21 |
+
print("HF_MODEL_REPO not set — expecting model_weights.pt already on disk.")
|
| 22 |
+
weights = target / "model_weights.pt"
|
| 23 |
+
if weights.exists():
|
| 24 |
+
print(f"Found {weights}")
|
| 25 |
+
else:
|
| 26 |
+
print(f"WARN: no weights at {weights}", file=sys.stderr)
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
weights = target / "model_weights.pt"
|
| 30 |
+
if weights.exists():
|
| 31 |
+
print(f"Model already present at {weights}")
|
| 32 |
+
return
|
| 33 |
+
|
| 34 |
+
print(f"Downloading {repo} → {target}")
|
| 35 |
+
from huggingface_hub import snapshot_download
|
| 36 |
+
|
| 37 |
+
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 38 |
+
|
| 39 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
snapshot_download(
|
| 41 |
+
repo_id=repo,
|
| 42 |
+
local_dir=str(target),
|
| 43 |
+
token=token,
|
| 44 |
+
allow_patterns=[
|
| 45 |
+
"model_weights.pt",
|
| 46 |
+
"config.json",
|
| 47 |
+
"tokenizer/*",
|
| 48 |
+
"*.json",
|
| 49 |
+
],
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if not weights.exists():
|
| 53 |
+
raise FileNotFoundError(
|
| 54 |
+
f"Download finished but {weights} missing. "
|
| 55 |
+
f"Upload model with: python deploy/huggingface/upload_model_to_hub.py"
|
| 56 |
+
)
|
| 57 |
+
print(f"Ready: {weights} ({weights.stat().st_size / 1e6:.1f} MB)")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
main()
|
src/deploy/huggingface/entrypoint.sh
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
ROOT="${APP_ROOT:-/app}"
|
| 4 |
+
python "$ROOT/deploy/huggingface/download_model.py"
|
| 5 |
+
exec uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-7860}" --app-dir "$ROOT/deploy/backend"
|
src/linguistic_features.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Handcrafted linguistic features for hybrid LCP models.
|
| 3 |
+
|
| 4 |
+
Literature: word frequency is the strongest single predictor; WordNet senses
|
| 5 |
+
capture ambiguity; syntax score captures structural difficulty.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import wordfreq
|
| 15 |
+
|
| 16 |
+
from syntax_complexity import analyze_syntax
|
| 17 |
+
|
| 18 |
+
FEATURE_NAMES = [
|
| 19 |
+
"log_word_frequency",
|
| 20 |
+
"word_length",
|
| 21 |
+
"syllable_estimate",
|
| 22 |
+
"wordnet_senses",
|
| 23 |
+
"syntax_complexity",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
N_LINGUISTIC_FEATURES = len(FEATURE_NAMES)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@lru_cache(maxsize=1)
|
| 30 |
+
def _wordnet_ready() -> bool:
|
| 31 |
+
try:
|
| 32 |
+
from nltk.corpus import wordnet as wn
|
| 33 |
+
|
| 34 |
+
wn.synsets("test")
|
| 35 |
+
return True
|
| 36 |
+
except LookupError:
|
| 37 |
+
import nltk
|
| 38 |
+
|
| 39 |
+
nltk.download("wordnet", quiet=True)
|
| 40 |
+
nltk.download("omw-1.4", quiet=True)
|
| 41 |
+
return True
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _syllable_estimate(word: str) -> float:
|
| 45 |
+
word = word.lower()
|
| 46 |
+
if not word:
|
| 47 |
+
return 0.0
|
| 48 |
+
vowels = "aeiouy"
|
| 49 |
+
count = 0
|
| 50 |
+
prev_vowel = False
|
| 51 |
+
for ch in word:
|
| 52 |
+
is_vowel = ch in vowels
|
| 53 |
+
if is_vowel and not prev_vowel:
|
| 54 |
+
count += 1
|
| 55 |
+
prev_vowel = is_vowel
|
| 56 |
+
return float(max(1, count))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _wordnet_sense_count(word: str) -> float:
|
| 60 |
+
_wordnet_ready()
|
| 61 |
+
from nltk.corpus import wordnet as wn
|
| 62 |
+
|
| 63 |
+
return float(len(wn.synsets(word.lower())))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def extract_linguistic_features(sentence: str, target_word: str) -> np.ndarray:
|
| 67 |
+
"""Return a 5-dim feature vector for one (sentence, target_word) pair."""
|
| 68 |
+
word = str(target_word).strip()
|
| 69 |
+
freq = wordfreq.word_frequency(word.lower(), "en")
|
| 70 |
+
log_freq = math.log10(freq + 1e-12)
|
| 71 |
+
word_len = len(word)
|
| 72 |
+
syllables = _syllable_estimate(word)
|
| 73 |
+
senses = _wordnet_sense_count(word)
|
| 74 |
+
syntax = analyze_syntax(str(sentence)).complexity_score
|
| 75 |
+
return np.array([log_freq, word_len, syllables, senses, syntax], dtype=np.float32)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def normalize_features(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 79 |
+
"""Z-score normalize feature matrix; return normalized, mean, std."""
|
| 80 |
+
mean = matrix.mean(axis=0)
|
| 81 |
+
std = matrix.std(axis=0)
|
| 82 |
+
std = np.where(std < 1e-6, 1.0, std)
|
| 83 |
+
return (matrix - mean) / std, mean, std
|
src/model_loading.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load trained LCP checkpoints with backward compatibility."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from transformers import AutoTokenizer
|
| 10 |
+
|
| 11 |
+
from two_head_model import TwoHeadModel, add_tgt_tokens
|
| 12 |
+
from utils import ENCODING_SPAN_MARK, MODELS, POOLING_CLS_CONCAT, POOLING_SPAN
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_model_from_checkpoint(ckpt_path: str | Path, device: torch.device | None = None):
|
| 16 |
+
ckpt_path = Path(ckpt_path)
|
| 17 |
+
device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 18 |
+
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 19 |
+
|
| 20 |
+
model_key = ckpt.get("model_key", "deberta")
|
| 21 |
+
encoding = ckpt.get("encoding", ENCODING_SPAN_MARK)
|
| 22 |
+
pooling = ckpt.get("pooling", POOLING_CLS_CONCAT) # legacy checkpoints
|
| 23 |
+
use_linguistic = ckpt.get("use_linguistic_features", False)
|
| 24 |
+
level_only = ckpt.get("level_only", False)
|
| 25 |
+
|
| 26 |
+
tokenizer = AutoTokenizer.from_pretrained(MODELS[model_key])
|
| 27 |
+
tgt_id, tgt_end_id = add_tgt_tokens(tokenizer)
|
| 28 |
+
|
| 29 |
+
model = TwoHeadModel(
|
| 30 |
+
model_name=MODELS[model_key],
|
| 31 |
+
pooling_mode=pooling,
|
| 32 |
+
use_linguistic_features=use_linguistic,
|
| 33 |
+
)
|
| 34 |
+
model.encoder.resize_token_embeddings(len(tokenizer))
|
| 35 |
+
model.load_state_dict(ckpt["model_state"])
|
| 36 |
+
model.set_tgt_token_ids(ckpt.get("tgt_id", tgt_id), ckpt.get("tgt_end_id", tgt_end_id))
|
| 37 |
+
model.to(device)
|
| 38 |
+
model.eval()
|
| 39 |
+
|
| 40 |
+
feat_mean = np.array(ckpt["feat_mean"]) if ckpt.get("feat_mean") is not None else None
|
| 41 |
+
feat_std = np.array(ckpt["feat_std"]) if ckpt.get("feat_std") is not None else None
|
| 42 |
+
|
| 43 |
+
meta = {
|
| 44 |
+
"model_key": model_key,
|
| 45 |
+
"encoding": encoding,
|
| 46 |
+
"pooling": pooling,
|
| 47 |
+
"use_linguistic_features": use_linguistic,
|
| 48 |
+
"level_only": level_only,
|
| 49 |
+
"tgt_id": ckpt.get("tgt_id", tgt_id),
|
| 50 |
+
"tgt_end_id": ckpt.get("tgt_end_id", tgt_end_id),
|
| 51 |
+
"feat_mean": feat_mean,
|
| 52 |
+
"feat_std": feat_std,
|
| 53 |
+
}
|
| 54 |
+
return model, tokenizer, meta
|
src/predictor.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UI-ready inference wrapper for the two-head complexity model.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import pickle
|
| 9 |
+
from dataclasses import asdict, dataclass
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
from linguistic_features import extract_linguistic_features
|
| 17 |
+
from model_loading import load_model_from_checkpoint
|
| 18 |
+
from utils import (
|
| 19 |
+
EXPORT_DIR,
|
| 20 |
+
HARD_LEVELS,
|
| 21 |
+
ID_TO_LEVEL,
|
| 22 |
+
ID_TO_REASON,
|
| 23 |
+
LEVEL_ORDER,
|
| 24 |
+
MODELS,
|
| 25 |
+
REASON_ORDER,
|
| 26 |
+
difficult_class_probability,
|
| 27 |
+
tokenize_lcp_input,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class PredictionResult:
|
| 33 |
+
complexity_level: str
|
| 34 |
+
level_id: int
|
| 35 |
+
level_probs: dict[str, float]
|
| 36 |
+
difficult_class_prob: float # P(Hard)+P(Very Hard) from 5-class softmax (auxiliary)
|
| 37 |
+
reason: str | None
|
| 38 |
+
reason_id: int | None
|
| 39 |
+
reason_probs: dict[str, float] | None
|
| 40 |
+
|
| 41 |
+
def to_dict(self) -> dict[str, Any]:
|
| 42 |
+
return asdict(self)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ComplexityPredictor:
|
| 46 |
+
"""Load once, call predict() from your UI or API."""
|
| 47 |
+
|
| 48 |
+
def __init__(
|
| 49 |
+
self,
|
| 50 |
+
model,
|
| 51 |
+
tokenizer,
|
| 52 |
+
meta: dict,
|
| 53 |
+
max_length: int = 192,
|
| 54 |
+
device: str | None = None,
|
| 55 |
+
):
|
| 56 |
+
self.model = model
|
| 57 |
+
self.tokenizer = tokenizer
|
| 58 |
+
self.meta = meta
|
| 59 |
+
self.model_key = meta["model_key"]
|
| 60 |
+
self.encoding = meta.get("encoding", "span_mark")
|
| 61 |
+
self.max_length = max_length
|
| 62 |
+
self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
| 63 |
+
self.model.to(self.device)
|
| 64 |
+
self.model.eval()
|
| 65 |
+
|
| 66 |
+
@classmethod
|
| 67 |
+
def from_checkpoint(cls, checkpoint_path: str | Path, device: str | None = None) -> "ComplexityPredictor":
|
| 68 |
+
dev = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
| 69 |
+
model, tokenizer, meta = load_model_from_checkpoint(checkpoint_path, dev)
|
| 70 |
+
return cls(model, tokenizer, meta, device=str(dev))
|
| 71 |
+
|
| 72 |
+
@classmethod
|
| 73 |
+
def load(cls, export_dir: str | Path, device: str | None = None) -> "ComplexityPredictor":
|
| 74 |
+
export_dir = Path(export_dir)
|
| 75 |
+
with open(export_dir / "config.json") as f:
|
| 76 |
+
config = json.load(f)
|
| 77 |
+
|
| 78 |
+
from transformers import AutoTokenizer
|
| 79 |
+
from two_head_model import TwoHeadModel
|
| 80 |
+
|
| 81 |
+
model_key = config["model_key"]
|
| 82 |
+
tokenizer = AutoTokenizer.from_pretrained(export_dir / "tokenizer")
|
| 83 |
+
model = TwoHeadModel(
|
| 84 |
+
model_name=MODELS[model_key],
|
| 85 |
+
pooling_mode=config.get("pooling", "span"),
|
| 86 |
+
use_linguistic_features=config.get("use_linguistic_features", False),
|
| 87 |
+
)
|
| 88 |
+
model.encoder.resize_token_embeddings(len(tokenizer))
|
| 89 |
+
weights = torch.load(export_dir / "model_weights.pt", map_location="cpu", weights_only=True)
|
| 90 |
+
model.load_state_dict(weights)
|
| 91 |
+
model.set_tgt_token_ids(config["tgt_id"], config.get("tgt_end_id"))
|
| 92 |
+
|
| 93 |
+
meta = {
|
| 94 |
+
"model_key": model_key,
|
| 95 |
+
"encoding": config.get("encoding", "span_mark"),
|
| 96 |
+
"pooling": config.get("pooling", "span"),
|
| 97 |
+
"use_linguistic_features": config.get("use_linguistic_features", False),
|
| 98 |
+
"tgt_id": config["tgt_id"],
|
| 99 |
+
"tgt_end_id": config.get("tgt_end_id"),
|
| 100 |
+
"feat_mean": np.array(config["feat_mean"]) if config.get("feat_mean") else None,
|
| 101 |
+
"feat_std": np.array(config["feat_std"]) if config.get("feat_std") else None,
|
| 102 |
+
}
|
| 103 |
+
return cls(model, tokenizer, meta, max_length=config.get("max_length", 192), device=device)
|
| 104 |
+
|
| 105 |
+
def save(self, export_dir: str | Path) -> Path:
|
| 106 |
+
export_dir = Path(export_dir)
|
| 107 |
+
export_dir.mkdir(parents=True, exist_ok=True)
|
| 108 |
+
|
| 109 |
+
config = {
|
| 110 |
+
"model_key": self.model_key,
|
| 111 |
+
"model_name": MODELS[self.model_key],
|
| 112 |
+
"tgt_id": self.meta.get("tgt_id"),
|
| 113 |
+
"tgt_end_id": self.meta.get("tgt_end_id"),
|
| 114 |
+
"encoding": self.encoding,
|
| 115 |
+
"pooling": self.meta.get("pooling", "span"),
|
| 116 |
+
"use_linguistic_features": self.meta.get("use_linguistic_features", False),
|
| 117 |
+
"feat_mean": self.meta.get("feat_mean").tolist() if self.meta.get("feat_mean") is not None else None,
|
| 118 |
+
"feat_std": self.meta.get("feat_std").tolist() if self.meta.get("feat_std") is not None else None,
|
| 119 |
+
"max_length": self.max_length,
|
| 120 |
+
"level_labels": LEVEL_ORDER,
|
| 121 |
+
"reason_labels": REASON_ORDER,
|
| 122 |
+
"hard_levels": list(HARD_LEVELS),
|
| 123 |
+
"inference_rule": "Reason is returned only when predicted level is Hard or Very Hard.",
|
| 124 |
+
"task": "5_class_complexity_prediction",
|
| 125 |
+
"outputs": "discrete_level_and_optional_reason_class",
|
| 126 |
+
}
|
| 127 |
+
with open(export_dir / "config.json", "w") as f:
|
| 128 |
+
json.dump(config, f, indent=2)
|
| 129 |
+
|
| 130 |
+
torch.save(self.model.state_dict(), export_dir / "model_weights.pt")
|
| 131 |
+
self.tokenizer.save_pretrained(export_dir / "tokenizer")
|
| 132 |
+
return export_dir
|
| 133 |
+
|
| 134 |
+
def save_pickle(self, pickle_path: str | Path) -> Path:
|
| 135 |
+
pickle_path = Path(pickle_path)
|
| 136 |
+
pickle_path.parent.mkdir(parents=True, exist_ok=True)
|
| 137 |
+
with open(pickle_path, "wb") as f:
|
| 138 |
+
pickle.dump(self, f)
|
| 139 |
+
return pickle_path
|
| 140 |
+
|
| 141 |
+
@classmethod
|
| 142 |
+
def load_pickle(cls, pickle_path: str | Path, device: str | None = None) -> "ComplexityPredictor":
|
| 143 |
+
with open(pickle_path, "rb") as f:
|
| 144 |
+
obj = pickle.load(f)
|
| 145 |
+
if not isinstance(obj, cls):
|
| 146 |
+
raise TypeError(f"Expected ComplexityPredictor, got {type(obj)}")
|
| 147 |
+
if device:
|
| 148 |
+
obj.device = torch.device(device)
|
| 149 |
+
obj.model.to(obj.device)
|
| 150 |
+
obj.model.eval()
|
| 151 |
+
return obj
|
| 152 |
+
|
| 153 |
+
@torch.no_grad()
|
| 154 |
+
def predict(self, sentence: str, target_word: str, corpus: str | None = None) -> PredictionResult:
|
| 155 |
+
enc = tokenize_lcp_input(
|
| 156 |
+
self.tokenizer,
|
| 157 |
+
sentence,
|
| 158 |
+
target_word,
|
| 159 |
+
encoding=self.encoding,
|
| 160 |
+
max_length=self.max_length,
|
| 161 |
+
corpus=corpus,
|
| 162 |
+
)
|
| 163 |
+
enc = {k: v.to(self.device) for k, v in enc.items()}
|
| 164 |
+
|
| 165 |
+
kwargs = {"input_ids": enc["input_ids"], "attention_mask": enc["attention_mask"]}
|
| 166 |
+
if self.meta.get("use_linguistic_features"):
|
| 167 |
+
feats = extract_linguistic_features(sentence, target_word)
|
| 168 |
+
if self.meta.get("feat_mean") is not None:
|
| 169 |
+
feats = (feats - self.meta["feat_mean"]) / self.meta["feat_std"]
|
| 170 |
+
kwargs["linguistic_features"] = torch.tensor(feats, dtype=torch.float32).unsqueeze(0).to(self.device)
|
| 171 |
+
|
| 172 |
+
out = self.model(**kwargs)
|
| 173 |
+
level_probs_t = torch.softmax(out.level_logits, dim=-1)[0].cpu()
|
| 174 |
+
reason_probs_t = torch.softmax(out.reason_logits, dim=-1)[0].cpu()
|
| 175 |
+
|
| 176 |
+
level_id = int(level_probs_t.argmax())
|
| 177 |
+
level_label = ID_TO_LEVEL[level_id]
|
| 178 |
+
level_probs = {ID_TO_LEVEL[i]: float(level_probs_t[i]) for i in range(5)}
|
| 179 |
+
diff_prob = difficult_class_probability(level_probs_t)
|
| 180 |
+
|
| 181 |
+
reason_label = None
|
| 182 |
+
reason_id = None
|
| 183 |
+
reason_probs = None
|
| 184 |
+
if level_label in HARD_LEVELS:
|
| 185 |
+
reason_id = int(reason_probs_t.argmax())
|
| 186 |
+
reason_label = ID_TO_REASON[reason_id]
|
| 187 |
+
reason_probs = {ID_TO_REASON[i]: float(reason_probs_t[i]) for i in range(3)}
|
| 188 |
+
|
| 189 |
+
return PredictionResult(
|
| 190 |
+
complexity_level=level_label,
|
| 191 |
+
level_id=level_id,
|
| 192 |
+
level_probs=level_probs,
|
| 193 |
+
difficult_class_prob=diff_prob,
|
| 194 |
+
reason=reason_label,
|
| 195 |
+
reason_id=reason_id,
|
| 196 |
+
reason_probs=reason_probs,
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
def predict_batch(self, items: list[dict[str, str]]) -> list[PredictionResult]:
|
| 200 |
+
return [self.predict(item["sentence"], item["target_word"], item.get("corpus")) for item in items]
|
src/reason_interventions.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Sentence edits for reason faithfulness tests (UI + 07_faithfulness.py).
|
| 3 |
+
|
| 4 |
+
Each reason type has one targeted edit; re-predict after edit to see if hardness drops.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
from syntax_complexity import find_hardest_span
|
| 13 |
+
from utils import REASON_ORDER
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class InterventionResult:
|
| 18 |
+
reason: str
|
| 19 |
+
original_sentence: str
|
| 20 |
+
edited_sentence: str
|
| 21 |
+
edit_method: str # "openai", "gemini", or "rule"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _call_llm(prompt: str) -> tuple[str | None, str]:
|
| 25 |
+
if os.environ.get("OPENAI_API_KEY"):
|
| 26 |
+
try:
|
| 27 |
+
from openai import OpenAI
|
| 28 |
+
|
| 29 |
+
client = OpenAI()
|
| 30 |
+
resp = client.chat.completions.create(
|
| 31 |
+
model=os.environ.get("FAITHFULNESS_LLM", "gpt-4o-mini"),
|
| 32 |
+
messages=[{"role": "user", "content": prompt}],
|
| 33 |
+
temperature=0,
|
| 34 |
+
max_tokens=256,
|
| 35 |
+
)
|
| 36 |
+
return resp.choices[0].message.content.strip(), "openai"
|
| 37 |
+
except Exception:
|
| 38 |
+
pass
|
| 39 |
+
if os.environ.get("GOOGLE_API_KEY"):
|
| 40 |
+
try:
|
| 41 |
+
import google.generativeai as genai
|
| 42 |
+
|
| 43 |
+
genai.configure(api_key=os.environ["GOOGLE_API_KEY"])
|
| 44 |
+
model = genai.GenerativeModel(os.environ.get("FAITHFULNESS_GEMINI", "gemini-1.5-flash"))
|
| 45 |
+
return model.generate_content(prompt).text.strip(), "gemini"
|
| 46 |
+
except Exception:
|
| 47 |
+
pass
|
| 48 |
+
return None, "rule"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _rule_synonym_swap(sentence: str, target_word: str) -> str:
|
| 52 |
+
simple = {
|
| 53 |
+
"frankincense": "incense",
|
| 54 |
+
"astonishment": "surprise",
|
| 55 |
+
"partiality": "bias",
|
| 56 |
+
"dominion": "rule",
|
| 57 |
+
"assemblies": "meetings",
|
| 58 |
+
"ewe": "sheep",
|
| 59 |
+
"scribe": "writer",
|
| 60 |
+
"inflammation": "swelling",
|
| 61 |
+
"treaty": "deal",
|
| 62 |
+
}
|
| 63 |
+
rep = simple.get(target_word.lower(), target_word)
|
| 64 |
+
if target_word in sentence:
|
| 65 |
+
return sentence.replace(target_word, rep, 1)
|
| 66 |
+
return sentence
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def edit_lexical_rarity(sentence: str, target_word: str, use_llm: bool = True) -> InterventionResult:
|
| 70 |
+
prompt = (
|
| 71 |
+
f"Replace ONLY the word '{target_word}' in this sentence with a common, simple synonym "
|
| 72 |
+
f"of the same meaning and part of speech. Change nothing else.\n"
|
| 73 |
+
f"Sentence: {sentence}\n"
|
| 74 |
+
f"Output only the edited sentence."
|
| 75 |
+
)
|
| 76 |
+
method = "rule"
|
| 77 |
+
edited = _rule_synonym_swap(sentence, target_word)
|
| 78 |
+
if use_llm:
|
| 79 |
+
llm_out, method = _call_llm(prompt)
|
| 80 |
+
if llm_out:
|
| 81 |
+
edited = llm_out
|
| 82 |
+
return InterventionResult("Lexical Rarity", sentence, edited, method)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def edit_contextual_ambiguity(sentence: str, target_word: str, use_llm: bool = True) -> InterventionResult:
|
| 86 |
+
prompt = (
|
| 87 |
+
f"Add at most 3 words to this sentence to make the meaning of '{target_word}' clear. "
|
| 88 |
+
f"Do NOT change or remove '{target_word}'.\n"
|
| 89 |
+
f"Sentence: {sentence}\n"
|
| 90 |
+
f"Output only the edited sentence."
|
| 91 |
+
)
|
| 92 |
+
method = "rule"
|
| 93 |
+
edited = f"{sentence} (meaning: {target_word})"
|
| 94 |
+
if use_llm:
|
| 95 |
+
llm_out, method = _call_llm(prompt)
|
| 96 |
+
if llm_out:
|
| 97 |
+
edited = llm_out
|
| 98 |
+
return InterventionResult("Contextual Ambiguity", sentence, edited, method)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def edit_syntactic_complexity(sentence: str, target_word: str, use_llm: bool = True) -> InterventionResult:
|
| 102 |
+
span, start, end = find_hardest_span(sentence)
|
| 103 |
+
method = "rule"
|
| 104 |
+
simplified = span
|
| 105 |
+
if use_llm and span.strip() != sentence.strip():
|
| 106 |
+
prompt = (
|
| 107 |
+
f"Simplify ONLY this phrase to plain English. Keep all key words including '{target_word}'.\n"
|
| 108 |
+
f"Phrase: {span}\n"
|
| 109 |
+
f"Output only the simplified phrase."
|
| 110 |
+
)
|
| 111 |
+
llm_out, method = _call_llm(prompt)
|
| 112 |
+
if llm_out:
|
| 113 |
+
simplified = llm_out
|
| 114 |
+
edited = sentence[:start] + simplified + sentence[end:]
|
| 115 |
+
return InterventionResult("Syntactic Complexity", sentence, edited, method)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
EDIT_FNS = {
|
| 119 |
+
"Lexical Rarity": edit_lexical_rarity,
|
| 120 |
+
"Contextual Ambiguity": edit_contextual_ambiguity,
|
| 121 |
+
"Syntactic Complexity": edit_syntactic_complexity,
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def apply_intervention(reason: str, sentence: str, target_word: str, use_llm: bool = True) -> InterventionResult:
|
| 126 |
+
if reason not in EDIT_FNS:
|
| 127 |
+
raise ValueError(f"Unknown reason: {reason}. Choose from {REASON_ORDER}")
|
| 128 |
+
return EDIT_FNS[reason](sentence, target_word, use_llm=use_llm)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def apply_all_interventions(sentence: str, target_word: str, use_llm: bool = True) -> list[InterventionResult]:
|
| 132 |
+
return [fn(sentence, target_word, use_llm=use_llm) for fn in EDIT_FNS.values()]
|
src/syntax_complexity.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Structural syntax analysis for word complexity project.
|
| 3 |
+
|
| 4 |
+
Provides:
|
| 5 |
+
- analyze_syntax(sentence): structural metrics for a sentence
|
| 6 |
+
- find_hardest_span(sentence): the most syntactically tangled span (for faithfulness edits)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from functools import lru_cache
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
import spacy
|
| 17 |
+
from spacy.tokens import Doc, Span, Token
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@lru_cache(maxsize=1)
|
| 21 |
+
def _load_nlp():
|
| 22 |
+
try:
|
| 23 |
+
return spacy.load("en_core_web_sm")
|
| 24 |
+
except OSError as exc:
|
| 25 |
+
raise RuntimeError(
|
| 26 |
+
"spaCy model 'en_core_web_sm' not found. Run: python -m spacy download en_core_web_sm"
|
| 27 |
+
) from exc
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class SyntaxMetrics:
|
| 32 |
+
token_count: int
|
| 33 |
+
max_tree_depth: int
|
| 34 |
+
avg_dependency_length: float
|
| 35 |
+
subordinate_clause_count: int
|
| 36 |
+
passive_voice_count: int
|
| 37 |
+
punctuation_density: float
|
| 38 |
+
complexity_score: float
|
| 39 |
+
|
| 40 |
+
def to_dict(self) -> dict[str, Any]:
|
| 41 |
+
return {
|
| 42 |
+
"token_count": self.token_count,
|
| 43 |
+
"max_tree_depth": self.max_tree_depth,
|
| 44 |
+
"avg_dependency_length": self.avg_dependency_length,
|
| 45 |
+
"subordinate_clause_count": self.subordinate_clause_count,
|
| 46 |
+
"passive_voice_count": self.passive_voice_count,
|
| 47 |
+
"punctuation_density": self.punctuation_density,
|
| 48 |
+
"complexity_score": self.complexity_score,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _token_depth(token: Token) -> int:
|
| 53 |
+
depth = 0
|
| 54 |
+
while token.head != token:
|
| 55 |
+
depth += 1
|
| 56 |
+
token = token.head
|
| 57 |
+
return depth
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _subtree_depth(token: Token) -> int:
|
| 61 |
+
children = list(token.subtree)
|
| 62 |
+
if not children:
|
| 63 |
+
return 0
|
| 64 |
+
return 1 + max(_subtree_depth(child) for child in token.children) if token.children else 1
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _dependency_length(token: Token) -> int:
|
| 68 |
+
if token.head == token:
|
| 69 |
+
return 0
|
| 70 |
+
return abs(token.i - token.head.i)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _count_subordinate_clauses(doc: Doc) -> int:
|
| 74 |
+
markers = {"ccomp", "xcomp", "advcl", "relcl", "acl"}
|
| 75 |
+
return sum(1 for tok in doc if tok.dep_ in markers)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _count_passive(doc: Doc) -> int:
|
| 79 |
+
return sum(
|
| 80 |
+
1
|
| 81 |
+
for tok in doc
|
| 82 |
+
if tok.dep_ in {"nsubjpass", "auxpass"} or (tok.tag_ == "VBN" and tok.dep_ == "ROOT")
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def analyze_syntax(sentence: str) -> SyntaxMetrics:
|
| 87 |
+
"""Return structural complexity metrics for a sentence."""
|
| 88 |
+
nlp = _load_nlp()
|
| 89 |
+
doc = nlp(sentence or "")
|
| 90 |
+
tokens = [t for t in doc if not t.is_space]
|
| 91 |
+
if not tokens:
|
| 92 |
+
return SyntaxMetrics(0, 0, 0.0, 0, 0, 0.0, 0.0)
|
| 93 |
+
|
| 94 |
+
depths = [_token_depth(t) for t in tokens]
|
| 95 |
+
dep_lengths = [_dependency_length(t) for t in tokens]
|
| 96 |
+
max_depth = max(depths)
|
| 97 |
+
avg_dep = sum(dep_lengths) / len(dep_lengths)
|
| 98 |
+
sub_clauses = _count_subordinate_clauses(doc)
|
| 99 |
+
passive = _count_passive(doc)
|
| 100 |
+
punct = len(re.findall(r"[,;:()\[\]{}\"']", sentence))
|
| 101 |
+
punct_density = punct / max(len(sentence), 1)
|
| 102 |
+
|
| 103 |
+
complexity_score = (
|
| 104 |
+
0.35 * max_depth
|
| 105 |
+
+ 0.25 * avg_dep
|
| 106 |
+
+ 0.20 * sub_clauses
|
| 107 |
+
+ 0.10 * passive
|
| 108 |
+
+ 0.10 * punct_density * 20
|
| 109 |
+
+ 0.05 * (len(tokens) / 30.0)
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
return SyntaxMetrics(
|
| 113 |
+
token_count=len(tokens),
|
| 114 |
+
max_tree_depth=max_depth,
|
| 115 |
+
avg_dependency_length=avg_dep,
|
| 116 |
+
subordinate_clause_count=sub_clauses,
|
| 117 |
+
passive_voice_count=passive,
|
| 118 |
+
punctuation_density=punct_density,
|
| 119 |
+
complexity_score=complexity_score,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _span_complexity(span: Span) -> float:
|
| 124 |
+
text = span.text.strip()
|
| 125 |
+
if not text:
|
| 126 |
+
return 0.0
|
| 127 |
+
metrics = analyze_syntax(text)
|
| 128 |
+
return metrics.complexity_score
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def find_hardest_span(sentence: str, min_tokens: int = 3) -> tuple[str, int, int]:
|
| 132 |
+
"""
|
| 133 |
+
Find the most syntactically tangled contiguous span in the sentence.
|
| 134 |
+
|
| 135 |
+
Returns (span_text, start_char, end_char).
|
| 136 |
+
"""
|
| 137 |
+
nlp = _load_nlp()
|
| 138 |
+
doc = nlp(sentence or "")
|
| 139 |
+
tokens = [t for t in doc if not t.is_space]
|
| 140 |
+
if len(tokens) < min_tokens:
|
| 141 |
+
return sentence, 0, len(sentence)
|
| 142 |
+
|
| 143 |
+
best_score = -1.0
|
| 144 |
+
best_span: Span | None = None
|
| 145 |
+
|
| 146 |
+
for start in range(len(tokens)):
|
| 147 |
+
for end in range(start + min_tokens, min(start + 25, len(tokens)) + 1):
|
| 148 |
+
span = doc[tokens[start].i : tokens[end - 1].i + 1]
|
| 149 |
+
score = _span_complexity(span)
|
| 150 |
+
if score > best_score:
|
| 151 |
+
best_score = score
|
| 152 |
+
best_span = span
|
| 153 |
+
|
| 154 |
+
if best_span is None:
|
| 155 |
+
return sentence, 0, len(sentence)
|
| 156 |
+
|
| 157 |
+
return best_span.text, best_span.start_char, best_span.end_char
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def syntax_signal_high(sentence: str, percentile_threshold: float = 0.6) -> bool:
|
| 161 |
+
"""Heuristic: sentence is structurally complex relative to typical text."""
|
| 162 |
+
score = analyze_syntax(sentence).complexity_score
|
| 163 |
+
return score >= percentile_threshold * 8.0
|
src/two_head_model.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage E — Two-head model for 5-class word complexity + 3-class reason.
|
| 3 |
+
|
| 4 |
+
No regression head. Outputs are discrete levels (Very Easy → Very Hard) and,
|
| 5 |
+
when Hard/Very Hard, one of three explainable difficulty causes.
|
| 6 |
+
|
| 7 |
+
Novel architecture (publish contribution): one encoder, dual heads, masked
|
| 8 |
+
reason loss, target-span pooling for word-in-context LCP.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
import torch.nn.functional as F
|
| 19 |
+
from transformers import AutoModel, AutoTokenizer, PreTrainedTokenizer
|
| 20 |
+
|
| 21 |
+
from linguistic_features import N_LINGUISTIC_FEATURES
|
| 22 |
+
from utils import (
|
| 23 |
+
MODELS,
|
| 24 |
+
POOLING_CLS_CONCAT,
|
| 25 |
+
POOLING_CLS_ONLY,
|
| 26 |
+
POOLING_SPAN,
|
| 27 |
+
POOLING_TGT_MARKER,
|
| 28 |
+
TGT_END_TOKEN,
|
| 29 |
+
TGT_TOKEN,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class ModelOutput:
|
| 35 |
+
level_logits: torch.Tensor
|
| 36 |
+
reason_logits: torch.Tensor
|
| 37 |
+
pooled: torch.Tensor
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class TwoHeadModel(nn.Module):
|
| 41 |
+
"""Shared encoder with level (5-class) and reason (3-class) heads."""
|
| 42 |
+
|
| 43 |
+
def __init__(
|
| 44 |
+
self,
|
| 45 |
+
model_name: str = MODELS["deberta"],
|
| 46 |
+
hidden_dropout: float = 0.1,
|
| 47 |
+
pooling_mode: str = POOLING_SPAN,
|
| 48 |
+
use_linguistic_features: bool = False,
|
| 49 |
+
# Legacy alias: cls_concat maps to cls_concat pooling
|
| 50 |
+
use_cls_concat: bool | None = None,
|
| 51 |
+
):
|
| 52 |
+
super().__init__()
|
| 53 |
+
if use_cls_concat is not None:
|
| 54 |
+
pooling_mode = POOLING_CLS_CONCAT if use_cls_concat else POOLING_SPAN
|
| 55 |
+
|
| 56 |
+
self.encoder = AutoModel.from_pretrained(model_name, torch_dtype=torch.float32)
|
| 57 |
+
hidden = self.encoder.config.hidden_size
|
| 58 |
+
self.pooling_mode = pooling_mode
|
| 59 |
+
self.use_linguistic_features = use_linguistic_features
|
| 60 |
+
self.model_name = model_name
|
| 61 |
+
|
| 62 |
+
extra = N_LINGUISTIC_FEATURES if use_linguistic_features else 0
|
| 63 |
+
if pooling_mode == POOLING_CLS_CONCAT:
|
| 64 |
+
head_in = hidden * 2 + extra
|
| 65 |
+
else:
|
| 66 |
+
head_in = hidden + extra
|
| 67 |
+
|
| 68 |
+
self.dropout = nn.Dropout(hidden_dropout)
|
| 69 |
+
self.level_head = nn.Linear(head_in, 5)
|
| 70 |
+
self.reason_head = nn.Linear(head_in, 3)
|
| 71 |
+
|
| 72 |
+
self._tgt_id: Optional[int] = None
|
| 73 |
+
self._tgt_end_id: Optional[int] = None
|
| 74 |
+
|
| 75 |
+
def set_tgt_token_ids(self, tgt_id: int, tgt_end_id: int | None = None) -> None:
|
| 76 |
+
self._tgt_id = tgt_id
|
| 77 |
+
self._tgt_end_id = tgt_end_id
|
| 78 |
+
|
| 79 |
+
def set_tgt_token_id(self, tgt_id: int) -> None:
|
| 80 |
+
self.set_tgt_token_ids(tgt_id, self._tgt_end_id)
|
| 81 |
+
|
| 82 |
+
def _span_pool(self, last_hidden: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor:
|
| 83 |
+
"""Mean-pool token hidden states inside [TGT] ... [/TGT] (target word subwords)."""
|
| 84 |
+
batch_size = input_ids.size(0)
|
| 85 |
+
pooled = []
|
| 86 |
+
cls_vec = last_hidden[:, 0, :]
|
| 87 |
+
|
| 88 |
+
for i in range(batch_size):
|
| 89 |
+
ids = input_ids[i]
|
| 90 |
+
open_pos = (ids == self._tgt_id).nonzero(as_tuple=True)[0] if self._tgt_id is not None else None
|
| 91 |
+
if open_pos is None or len(open_pos) == 0:
|
| 92 |
+
pooled.append(cls_vec[i])
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
start = int(open_pos[0].item()) + 1
|
| 96 |
+
end = len(ids)
|
| 97 |
+
if self._tgt_end_id is not None:
|
| 98 |
+
close_pos = (ids == self._tgt_end_id).nonzero(as_tuple=True)[0]
|
| 99 |
+
close_after = close_pos[close_pos > open_pos[0]]
|
| 100 |
+
if len(close_after) > 0:
|
| 101 |
+
end = int(close_after[0].item())
|
| 102 |
+
|
| 103 |
+
span_idx = [j for j in range(start, end) if ids[j].item() != 0] # skip pad
|
| 104 |
+
if not span_idx:
|
| 105 |
+
pooled.append(last_hidden[i, int(open_pos[0].item()), :])
|
| 106 |
+
else:
|
| 107 |
+
vecs = last_hidden[i, span_idx, :]
|
| 108 |
+
pooled.append(vecs.mean(dim=0))
|
| 109 |
+
|
| 110 |
+
return torch.stack(pooled, dim=0)
|
| 111 |
+
|
| 112 |
+
def _first_marker_pool(self, last_hidden: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor:
|
| 113 |
+
cls_vec = last_hidden[:, 0, :]
|
| 114 |
+
if self._tgt_id is None:
|
| 115 |
+
return cls_vec
|
| 116 |
+
|
| 117 |
+
batch_size = input_ids.size(0)
|
| 118 |
+
vecs = []
|
| 119 |
+
for i in range(batch_size):
|
| 120 |
+
positions = (input_ids[i] == self._tgt_id).nonzero(as_tuple=True)[0]
|
| 121 |
+
if len(positions) > 0:
|
| 122 |
+
vecs.append(last_hidden[i, positions[0], :])
|
| 123 |
+
else:
|
| 124 |
+
vecs.append(cls_vec[i])
|
| 125 |
+
return torch.stack(vecs, dim=0)
|
| 126 |
+
|
| 127 |
+
def _pool(self, last_hidden: torch.Tensor, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 128 |
+
cls_vec = last_hidden[:, 0, :]
|
| 129 |
+
|
| 130 |
+
if self.pooling_mode == POOLING_CLS_ONLY:
|
| 131 |
+
return cls_vec
|
| 132 |
+
if self.pooling_mode == POOLING_SPAN:
|
| 133 |
+
return self._span_pool(last_hidden, input_ids)
|
| 134 |
+
if self.pooling_mode == POOLING_TGT_MARKER:
|
| 135 |
+
return self._first_marker_pool(last_hidden, input_ids)
|
| 136 |
+
if self.pooling_mode == POOLING_CLS_CONCAT:
|
| 137 |
+
tgt_vec = self._first_marker_pool(last_hidden, input_ids)
|
| 138 |
+
return torch.cat([cls_vec, tgt_vec], dim=-1)
|
| 139 |
+
|
| 140 |
+
return self._span_pool(last_hidden, input_ids)
|
| 141 |
+
|
| 142 |
+
def forward(
|
| 143 |
+
self,
|
| 144 |
+
input_ids: torch.Tensor,
|
| 145 |
+
attention_mask: torch.Tensor,
|
| 146 |
+
linguistic_features: Optional[torch.Tensor] = None,
|
| 147 |
+
) -> ModelOutput:
|
| 148 |
+
outputs = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
|
| 149 |
+
pooled = self._pool(outputs.last_hidden_state, input_ids, attention_mask)
|
| 150 |
+
|
| 151 |
+
if self.use_linguistic_features:
|
| 152 |
+
if linguistic_features is None:
|
| 153 |
+
raise ValueError("linguistic_features required when use_linguistic_features=True")
|
| 154 |
+
pooled = torch.cat([pooled, linguistic_features], dim=-1)
|
| 155 |
+
|
| 156 |
+
pooled = self.dropout(pooled)
|
| 157 |
+
# Encoder may run in float16 on GPU; classification heads stay float32.
|
| 158 |
+
pooled = pooled.to(dtype=self.level_head.weight.dtype)
|
| 159 |
+
|
| 160 |
+
return ModelOutput(
|
| 161 |
+
level_logits=self.level_head(pooled),
|
| 162 |
+
reason_logits=self.reason_head(pooled),
|
| 163 |
+
pooled=pooled,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def add_tgt_tokens(tokenizer: PreTrainedTokenizer) -> tuple[int, int]:
|
| 168 |
+
"""Add [TGT] and [/TGT] special tokens; return their ids."""
|
| 169 |
+
special = {"additional_special_tokens": [TGT_TOKEN, TGT_END_TOKEN]}
|
| 170 |
+
tokenizer.add_special_tokens(special)
|
| 171 |
+
return (
|
| 172 |
+
tokenizer.convert_tokens_to_ids(TGT_TOKEN),
|
| 173 |
+
tokenizer.convert_tokens_to_ids(TGT_END_TOKEN),
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def add_tgt_token(tokenizer: PreTrainedTokenizer) -> int:
|
| 178 |
+
"""Backward-compatible: add markers and return open [TGT] id."""
|
| 179 |
+
open_id, _ = add_tgt_tokens(tokenizer)
|
| 180 |
+
return open_id
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def compute_loss(
|
| 184 |
+
level_logits: torch.Tensor,
|
| 185 |
+
reason_logits: torch.Tensor,
|
| 186 |
+
level_ids: torch.Tensor,
|
| 187 |
+
reason_ids: torch.Tensor,
|
| 188 |
+
reason_mask: torch.Tensor,
|
| 189 |
+
reason_class_weights: Optional[torch.Tensor] = None,
|
| 190 |
+
lambda_reason: float = 1.0,
|
| 191 |
+
level_only: bool = False,
|
| 192 |
+
) -> tuple[torch.Tensor, dict]:
|
| 193 |
+
level_loss = F.cross_entropy(level_logits, level_ids)
|
| 194 |
+
|
| 195 |
+
if level_only:
|
| 196 |
+
return level_loss, {"level_loss": level_loss.item(), "reason_loss": 0.0, "total_loss": level_loss.item()}
|
| 197 |
+
|
| 198 |
+
per_row = F.cross_entropy(
|
| 199 |
+
reason_logits,
|
| 200 |
+
reason_ids,
|
| 201 |
+
weight=reason_class_weights,
|
| 202 |
+
reduction="none",
|
| 203 |
+
)
|
| 204 |
+
masked = per_row * reason_mask
|
| 205 |
+
denom = reason_mask.sum().clamp(min=1.0)
|
| 206 |
+
reason_loss = masked.sum() / denom
|
| 207 |
+
total = level_loss + lambda_reason * reason_loss
|
| 208 |
+
|
| 209 |
+
return total, {
|
| 210 |
+
"level_loss": level_loss.item(),
|
| 211 |
+
"reason_loss": reason_loss.item(),
|
| 212 |
+
"total_loss": total.item(),
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def load_tokenizer(model_key: str = "deberta") -> PreTrainedTokenizer:
|
| 217 |
+
return AutoTokenizer.from_pretrained(MODELS[model_key])
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def build_model(
|
| 221 |
+
model_key: str = "deberta",
|
| 222 |
+
pooling_mode: str = POOLING_SPAN,
|
| 223 |
+
use_linguistic_features: bool = False,
|
| 224 |
+
use_cls_concat: bool | None = None,
|
| 225 |
+
) -> TwoHeadModel:
|
| 226 |
+
return TwoHeadModel(
|
| 227 |
+
model_name=MODELS[model_key],
|
| 228 |
+
pooling_mode=pooling_mode,
|
| 229 |
+
use_linguistic_features=use_linguistic_features,
|
| 230 |
+
use_cls_concat=use_cls_concat,
|
| 231 |
+
)
|
src/ui/__init__.py
ADDED
|
File without changes
|
src/ui/efficiency_panel.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Compare local model vs AI API: latency and estimated cost per query.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
|
| 9 |
+
LEVEL_PROMPT = """Rate the complexity of the target word in context.
|
| 10 |
+
Levels: Very Easy, Easy, Medium, Hard, Very Hard.
|
| 11 |
+
Sentence: {sentence}
|
| 12 |
+
Target word: {target_word}
|
| 13 |
+
Reply with only the level label."""
|
| 14 |
+
|
| 15 |
+
# USD per 1k queries (rough; matches 08_efficiency.py defaults)
|
| 16 |
+
COST_PER_1K = {
|
| 17 |
+
"openai_gpt4o_mini": 0.15,
|
| 18 |
+
"gemini_flash": 0.075,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# Typical latency when API is used (ms) — used when no live API call
|
| 22 |
+
TYPICAL_AI_LATENCY_MS = {
|
| 23 |
+
"openai_gpt4o_mini": 1200.0,
|
| 24 |
+
"gemini_flash": 900.0,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class QueryComparison:
|
| 30 |
+
local_latency_ms: float
|
| 31 |
+
ai_latency_ms: float
|
| 32 |
+
ai_provider: str
|
| 33 |
+
ai_cost_usd: float
|
| 34 |
+
ai_cost_per_1k_usd: float
|
| 35 |
+
local_cost_usd: float
|
| 36 |
+
speedup_factor: float
|
| 37 |
+
prompt_tokens_est: int
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _estimate_tokens(text: str) -> int:
|
| 41 |
+
return max(1, len(text.split()) + len(text) // 4)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def estimate_ai_cost(sentence: str, target_word: str, provider: str = "openai_gpt4o_mini") -> float:
|
| 45 |
+
prompt = LEVEL_PROMPT.format(sentence=sentence, target_word=target_word)
|
| 46 |
+
tokens = _estimate_tokens(prompt) + 10 # short reply
|
| 47 |
+
# gpt-4o-mini ballpark: ~$0.15/1M input + $0.60/1M output — ~$0.0002 per simple call
|
| 48 |
+
cost_per_1k = COST_PER_1K.get(provider, 0.15)
|
| 49 |
+
return (cost_per_1k / 1000.0) * (tokens / 200.0)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def compare_query(sentence: str, target_word: str, local_latency_ms: float) -> QueryComparison:
|
| 53 |
+
provider = "openai_gpt4o_mini"
|
| 54 |
+
prompt = LEVEL_PROMPT.format(sentence=sentence, target_word=target_word)
|
| 55 |
+
tokens = _estimate_tokens(prompt)
|
| 56 |
+
ai_cost = estimate_ai_cost(sentence, target_word, provider)
|
| 57 |
+
ai_latency = TYPICAL_AI_LATENCY_MS[provider]
|
| 58 |
+
local_cost = 0.0
|
| 59 |
+
speedup = ai_latency / max(local_latency_ms, 0.1)
|
| 60 |
+
return QueryComparison(
|
| 61 |
+
local_latency_ms=local_latency_ms,
|
| 62 |
+
ai_latency_ms=ai_latency,
|
| 63 |
+
ai_provider="GPT-4o-mini (estimated)",
|
| 64 |
+
ai_cost_usd=ai_cost,
|
| 65 |
+
ai_cost_per_1k_usd=COST_PER_1K[provider],
|
| 66 |
+
local_cost_usd=local_cost,
|
| 67 |
+
speedup_factor=speedup,
|
| 68 |
+
prompt_tokens_est=tokens,
|
| 69 |
+
)
|
src/utils.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared constants and helpers for the word complexity project."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
PROJECT_ROOT = Path(__file__).resolve().parent
|
| 6 |
+
DATA_DIR = PROJECT_ROOT / "data"
|
| 7 |
+
OUTPUT_DIR = PROJECT_ROOT / "outputs"
|
| 8 |
+
CHECKPOINT_DIR = PROJECT_ROOT / "checkpoints"
|
| 9 |
+
EXPORT_DIR = PROJECT_ROOT / "exported_models"
|
| 10 |
+
|
| 11 |
+
LEVEL_ORDER = ["Very Easy", "Easy", "Medium", "Hard", "Very Hard"]
|
| 12 |
+
REASON_ORDER = ["Lexical Rarity", "Contextual Ambiguity", "Syntactic Complexity"]
|
| 13 |
+
HARD_LEVELS = {"Hard", "Very Hard"}
|
| 14 |
+
|
| 15 |
+
LEVEL_TO_ID = {level: idx for idx, level in enumerate(LEVEL_ORDER)}
|
| 16 |
+
ID_TO_LEVEL = {idx: level for level, idx in LEVEL_TO_ID.items()}
|
| 17 |
+
REASON_TO_ID = {reason: idx for idx, reason in enumerate(REASON_ORDER)}
|
| 18 |
+
ID_TO_REASON = {idx: reason for reason, idx in REASON_TO_ID.items()}
|
| 19 |
+
NONE_REASON_ID = -1
|
| 20 |
+
|
| 21 |
+
TGT_TOKEN = "[TGT]"
|
| 22 |
+
TGT_END_TOKEN = "[/TGT]"
|
| 23 |
+
|
| 24 |
+
# Input encoding strategies (SemEval / ABSA literature)
|
| 25 |
+
ENCODING_SPAN_MARK = "span_mark" # [TGT] word [/TGT] inside sentence
|
| 26 |
+
ENCODING_PAIR_CAMBRIDGE = "pair_cambridge" # [CLS] target [SEP] sentence
|
| 27 |
+
ENCODING_PAIR_CONTEXT = "pair_context" # sentence [SEP] target (SemEval common)
|
| 28 |
+
|
| 29 |
+
# Pooling strategies for target-focused readout
|
| 30 |
+
POOLING_SPAN = "span" # mean of tokens inside marked span (default)
|
| 31 |
+
POOLING_CLS_CONCAT = "cls_concat" # legacy: [CLS] + first [TGT]
|
| 32 |
+
POOLING_TGT_MARKER = "tgt_marker" # first [TGT] token only
|
| 33 |
+
POOLING_CLS_ONLY = "cls_only" # [CLS] only (for pair encoding)
|
| 34 |
+
|
| 35 |
+
MODELS = {
|
| 36 |
+
"deberta": "microsoft/deberta-v3-base",
|
| 37 |
+
"distilbert": "distilbert-base-uncased",
|
| 38 |
+
"roberta": "roberta-base",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
MERGE_KEYS = ["id", "sentence", "target_word", "complexity_level"]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def level_id(level: str) -> int:
|
| 45 |
+
return LEVEL_TO_ID[level]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def reason_id(reason: str) -> int:
|
| 49 |
+
if reason in ("NONE", None) or (isinstance(reason, float) and str(reason) == "nan"):
|
| 50 |
+
return NONE_REASON_ID
|
| 51 |
+
return REASON_TO_ID[reason]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def is_hard_level(level: str) -> bool:
|
| 55 |
+
return level in HARD_LEVELS
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def wrap_target_word(sentence: str, target_word: str) -> str:
|
| 59 |
+
"""Wrap target word with open/close span markers (ABSA aspect-marker style)."""
|
| 60 |
+
if not target_word or target_word not in sentence:
|
| 61 |
+
return sentence
|
| 62 |
+
marked = f"{TGT_TOKEN} {target_word} {TGT_END_TOKEN}"
|
| 63 |
+
return sentence.replace(target_word, marked, 1)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def build_model_input(
|
| 67 |
+
sentence: str,
|
| 68 |
+
target_word: str,
|
| 69 |
+
encoding: str = ENCODING_SPAN_MARK,
|
| 70 |
+
corpus: str | None = None,
|
| 71 |
+
) -> str | tuple[str, str]:
|
| 72 |
+
"""
|
| 73 |
+
Build tokenizer input for LCP.
|
| 74 |
+
|
| 75 |
+
Returns a string for span marking, or (text_a, text_b) for pair encodings.
|
| 76 |
+
"""
|
| 77 |
+
sentence = str(sentence)
|
| 78 |
+
target_word = str(target_word)
|
| 79 |
+
|
| 80 |
+
if encoding == ENCODING_SPAN_MARK:
|
| 81 |
+
return wrap_target_word(sentence, target_word)
|
| 82 |
+
|
| 83 |
+
if encoding == ENCODING_PAIR_CAMBRIDGE:
|
| 84 |
+
return target_word, sentence
|
| 85 |
+
|
| 86 |
+
if encoding == ENCODING_PAIR_CONTEXT:
|
| 87 |
+
if corpus:
|
| 88 |
+
return f"{corpus} {target_word}", sentence
|
| 89 |
+
return sentence, target_word
|
| 90 |
+
|
| 91 |
+
raise ValueError(f"Unknown encoding: {encoding}")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def difficult_class_probability(level_probs) -> float:
|
| 95 |
+
"""P(Hard) + P(Very Hard) from the 5-class softmax — auxiliary only, not a regression score."""
|
| 96 |
+
if hasattr(level_probs, "tolist"):
|
| 97 |
+
level_probs = level_probs.tolist()
|
| 98 |
+
if isinstance(level_probs, dict):
|
| 99 |
+
return float(level_probs.get("Hard", 0) + level_probs.get("Very Hard", 0))
|
| 100 |
+
return float(level_probs[3] + level_probs[4])
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def ensure_dirs() -> None:
|
| 104 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 105 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 106 |
+
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
|
| 107 |
+
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def tokenize_lcp_input(
|
| 111 |
+
tokenizer,
|
| 112 |
+
sentence: str,
|
| 113 |
+
target_word: str,
|
| 114 |
+
encoding: str = ENCODING_SPAN_MARK,
|
| 115 |
+
max_length: int = 192,
|
| 116 |
+
corpus: str | None = None,
|
| 117 |
+
):
|
| 118 |
+
"""Tokenize a (sentence, target_word) pair for the LCP model."""
|
| 119 |
+
built = build_model_input(sentence, target_word, encoding=encoding, corpus=corpus)
|
| 120 |
+
if isinstance(built, tuple):
|
| 121 |
+
return tokenizer(
|
| 122 |
+
built[0],
|
| 123 |
+
built[1],
|
| 124 |
+
truncation=True,
|
| 125 |
+
max_length=max_length,
|
| 126 |
+
padding="max_length",
|
| 127 |
+
return_tensors="pt",
|
| 128 |
+
)
|
| 129 |
+
return tokenizer(
|
| 130 |
+
built,
|
| 131 |
+
truncation=True,
|
| 132 |
+
max_length=max_length,
|
| 133 |
+
padding="max_length",
|
| 134 |
+
return_tensors="pt",
|
| 135 |
+
)
|