Skip to content

Commit b62217a

Browse files
committed
fix: correctness in training lock, prediction export, outliers, correlation
- hold the training lock through the is_best update and commit to avoid a race - export predictions over the full dataset instead of a mismatched re-split - outliers guided page uses the correct z-score default (3.0) - correlation guided page detects high-correlation pairs across all columns
1 parent cd54548 commit b62217a

3 files changed

Lines changed: 99 additions & 94 deletions

File tree

backend/app/api/routes/model.py

Lines changed: 84 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import pandas as pd
1111
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
1212
from fastapi.responses import StreamingResponse
13-
from sklearn.model_selection import train_test_split
1413
from sqlalchemy.ext.asyncio import AsyncSession
1514
from sqlalchemy import select
1615

@@ -80,92 +79,91 @@ async def start_training(
8079
raise HTTPException(status_code=409, detail="Training already in progress for this project.")
8180

8281
try:
83-
loop = asyncio.get_running_loop()
84-
_pipeline, task_type, metrics = await loop.run_in_executor(
85-
None,
86-
partial(
87-
train_model,
88-
df=df,
89-
model_type=body.model_type,
90-
target_column=body.target_column,
91-
test_size=body.test_size,
92-
hyperparameters=body.hyperparameters,
93-
task_type_override=body.task_type,
94-
),
95-
)
96-
except ValueError as exc:
97-
raise HTTPException(status_code=400, detail=str(exc))
98-
except Exception as exc:
99-
logger.exception(f"Training failed for project {project_id}")
100-
raise HTTPException(status_code=500, detail=f"Training failed: {exc}")
101-
finally:
102-
await release_training_lock(project_id)
103-
104-
sanitized_metrics = sanitize_for_json(metrics)
105-
106-
# Serialize the trained pipeline and persist it so export/predict don't re-train
107-
model_id = uuid.uuid4()
108-
109-
def _serialize():
110-
buf = io.BytesIO()
111-
joblib.dump(_pipeline, buf)
112-
return buf.getvalue()
113-
114-
model_bytes = await loop.run_in_executor(None, _serialize)
115-
model_stored = False
116-
try:
117-
await upload_model(str(model_id), model_bytes)
118-
model_stored = True
119-
except Exception as storage_exc:
120-
logger.error(f"Model storage failed for project {project_id}: {storage_exc}")
121-
122-
best_q = await db.execute(
123-
select(TrainedModel).where(
124-
TrainedModel.project_id == parse_project_id(project_id),
125-
TrainedModel.is_best == True, # noqa: E712
82+
try:
83+
loop = asyncio.get_running_loop()
84+
_pipeline, task_type, metrics = await loop.run_in_executor(
85+
None,
86+
partial(
87+
train_model,
88+
df=df,
89+
model_type=body.model_type,
90+
target_column=body.target_column,
91+
test_size=body.test_size,
92+
hyperparameters=body.hyperparameters,
93+
task_type_override=body.task_type,
94+
),
95+
)
96+
except ValueError as exc:
97+
raise HTTPException(status_code=400, detail=str(exc))
98+
except Exception as exc:
99+
logger.exception(f"Training failed for project {project_id}")
100+
raise HTTPException(status_code=500, detail=f"Training failed: {exc}")
101+
102+
sanitized_metrics = sanitize_for_json(metrics)
103+
model_id = uuid.uuid4()
104+
105+
def _serialize():
106+
buf = io.BytesIO()
107+
joblib.dump(_pipeline, buf)
108+
return buf.getvalue()
109+
110+
model_bytes = await loop.run_in_executor(None, _serialize)
111+
model_stored = False
112+
try:
113+
await upload_model(str(model_id), model_bytes)
114+
model_stored = True
115+
except Exception as storage_exc:
116+
logger.error(f"Model storage failed for project {project_id}: {storage_exc}")
117+
118+
best_q = await db.execute(
119+
select(TrainedModel).where(
120+
TrainedModel.project_id == parse_project_id(project_id),
121+
TrainedModel.is_best == True, # noqa: E712
122+
)
126123
)
127-
)
128-
existing_bests = list(best_q.scalars().all())
129-
130-
if existing_bests:
131-
if task_type == "classification":
132-
best_existing = max((m.metrics or {}).get("accuracy", 0.0) for m in existing_bests)
133-
is_best = sanitized_metrics.get("accuracy", 0.0) >= best_existing
124+
existing_bests = list(best_q.scalars().all())
125+
126+
if existing_bests:
127+
if task_type == "classification":
128+
best_existing = max((m.metrics or {}).get("accuracy", 0.0) for m in existing_bests)
129+
is_best = sanitized_metrics.get("accuracy", 0.0) >= best_existing
130+
else:
131+
best_existing = max((m.metrics or {}).get("r2", float("-inf")) for m in existing_bests)
132+
is_best = sanitized_metrics.get("r2", float("-inf")) >= best_existing
133+
if is_best:
134+
for m in existing_bests:
135+
m.is_best = False
134136
else:
135-
best_existing = max((m.metrics or {}).get("r2", float("-inf")) for m in existing_bests)
136-
is_best = sanitized_metrics.get("r2", float("-inf")) >= best_existing
137-
if is_best:
138-
for m in existing_bests:
139-
m.is_best = False
140-
else:
141-
is_best = True
142-
143-
model_row = TrainedModel(
144-
id=model_id,
145-
project_id=parse_project_id(project_id),
146-
model_type=body.model_type,
147-
target_column=body.target_column,
148-
task_type=task_type,
149-
metrics=sanitized_metrics,
150-
parameters=body.hyperparameters,
151-
is_best=is_best,
152-
model_path=str(model_id) if model_stored else None,
153-
)
154-
db.add(model_row)
137+
is_best = True
138+
139+
model_row = TrainedModel(
140+
id=model_id,
141+
project_id=parse_project_id(project_id),
142+
model_type=body.model_type,
143+
target_column=body.target_column,
144+
task_type=task_type,
145+
metrics=sanitized_metrics,
146+
parameters=body.hyperparameters,
147+
is_best=is_best,
148+
model_path=str(model_id) if model_stored else None,
149+
)
150+
db.add(model_row)
155151

156-
project.current_step = "evaluation"
157-
project.status = "completed"
158-
await db.commit()
152+
project.current_step = "evaluation"
153+
project.status = "completed"
154+
await db.commit()
159155

160-
logger.info(f"Training complete for project {project_id}: {body.model_type} ({task_type}) acc={sanitized_metrics.get('accuracy')}")
156+
logger.info(f"Training complete for project {project_id}: {body.model_type} ({task_type}) acc={sanitized_metrics.get('accuracy')}")
161157

162-
return {
163-
"success": True,
164-
"model_type": body.model_type,
165-
"target_column": body.target_column,
166-
"task_type": task_type,
167-
"metrics": sanitized_metrics,
168-
}
158+
return {
159+
"success": True,
160+
"model_type": body.model_type,
161+
"target_column": body.target_column,
162+
"task_type": task_type,
163+
"metrics": sanitized_metrics,
164+
}
165+
finally:
166+
await release_training_lock(project_id)
169167

170168

171169
@router.get("/evaluate/{project_id}", response_model=EvaluateResponse)
@@ -496,7 +494,7 @@ async def export_predictions(
496494
current_user: User = Depends(get_current_user),
497495
db: AsyncSession = Depends(get_db),
498496
) -> StreamingResponse:
499-
"""Loads the stored model, runs inference on a test split, returns CSV with actual vs predicted."""
497+
"""Loads the stored model and returns a CSV of actual vs predicted over the full dataset."""
500498
best_model, df = await _get_best_model_and_df(project_id, current_user, db)
501499
loop = asyncio.get_running_loop()
502500
pipe = await _load_pipeline(best_model, loop)
@@ -509,11 +507,9 @@ def _build_csv():
509507
if bool_cols:
510508
X = X.copy()
511509
X[bool_cols] = X[bool_cols].astype(np.int8)
512-
_, X_test, _, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
513-
y_pred = pipe.predict(X_test)
514-
out = X_test.copy()
515-
out["actual"] = y_test.values
516-
out["predicted"] = y_pred
510+
out = X.copy()
511+
out["actual"] = y.values
512+
out["predicted"] = pipe.predict(X)
517513
buf = io.StringIO()
518514
out.to_csv(buf, index=False)
519515
return buf.getvalue()

frontend/src/pages/CorrelationPage.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,20 @@ export default function CorrelationPage({ projectId, onNext }: CorrelationPagePr
4949
}, [corrData, displayFeatures])
5050

5151
const highCorrPairs = useMemo(() => {
52+
if (!corrData) return []
5253
const pairs: { pair: string; corr: number; dropCol: string }[] = []
53-
for (let i = 0; i < displayFeatures.length; i++) {
54-
for (let j = i + 1; j < displayFeatures.length; j++) {
55-
const val = corrMatrix[i]?.[j] ?? 0
54+
for (let i = 0; i < features.length; i++) {
55+
for (let j = i + 1; j < features.length; j++) {
56+
const a = features[i]
57+
const b = features[j]
58+
const val = corrData.correlation_matrix[a]?.[b] ?? 0
5659
if (Math.abs(val) >= threshold) {
57-
pairs.push({ pair: `${displayFeatures[i]} & ${displayFeatures[j]}`, corr: val, dropCol: displayFeatures[j] })
60+
pairs.push({ pair: `${a} & ${b}`, corr: val, dropCol: b })
5861
}
5962
}
6063
}
6164
return pairs
62-
}, [corrMatrix, displayFeatures, threshold])
65+
}, [corrData, features, threshold])
6366

6467
// Collect the actual column names to drop from marked pairs
6568
const columnsToDrop = useMemo(() => {

frontend/src/pages/OutliersPage.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export default function OutliersPage({ projectId, onNext }: OutliersPageProps) {
5252
[info, categoricalSet]
5353
)
5454

55+
const changeMethod = (m: string) => {
56+
setMethod(m)
57+
setThreshold(m === 'zscore' ? '3' : '1.5')
58+
setMethodOpen(false)
59+
}
60+
5561
const detectMutation = useMutation({
5662
mutationFn: () =>
5763
preprocessingApi.detectOutliers(projectId, {
@@ -127,7 +133,7 @@ export default function OutliersPage({ projectId, onNext }: OutliersPageProps) {
127133
{methodOpen && (
128134
<div className="absolute top-full left-0 right-0 mt-1 bg-[#1c2333] border border-[#2d3748] rounded shadow-xl z-20">
129135
{METHODS.map(m => (
130-
<button key={m.value} onClick={() => { setMethod(m.value); setMethodOpen(false) }}
136+
<button key={m.value} onClick={() => changeMethod(m.value)}
131137
className={`w-full text-left px-3 py-2 text-xs hover:bg-[#f9731618] hover:text-[#f97316] transition-colors ${method === m.value ? 'text-[#f97316] bg-[#f9731610]' : 'text-[#94a3b8]'}`}>
132138
{m.label}
133139
</button>

0 commit comments

Comments
 (0)