diff --git a/DashAI/alembic/versions/b7e4d2a19c63_merge_reports_and_develop_heads.py b/DashAI/alembic/versions/b7e4d2a19c63_merge_reports_and_develop_heads.py
new file mode 100644
index 000000000..b864ac619
--- /dev/null
+++ b/DashAI/alembic/versions/b7e4d2a19c63_merge_reports_and_develop_heads.py
@@ -0,0 +1,23 @@
+"""merge reports and develop heads
+
+Revision ID: b7e4d2a19c63
+Revises: a5f2c71e9d40, e6c3b91a7d48
+Create Date: 2026-09-07 12:00:00.000000
+
+"""
+
+from typing import Sequence, Union
+
+# revision identifiers, used by Alembic.
+revision: str = "b7e4d2a19c63"
+down_revision: Union[str, Sequence[str], None] = ("a5f2c71e9d40", "e6c3b91a7d48")
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/DashAI/alembic/versions/e6c3b91a7d48_add_report_table.py b/DashAI/alembic/versions/e6c3b91a7d48_add_report_table.py
new file mode 100644
index 000000000..84f5a82fb
--- /dev/null
+++ b/DashAI/alembic/versions/e6c3b91a7d48_add_report_table.py
@@ -0,0 +1,57 @@
+"""add report table
+
+Revision ID: e6c3b91a7d48
+Revises: c4e8a1d20f3b
+Create Date: 2026-07-30
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "e6c3b91a7d48"
+down_revision: Union[str, None] = "c4e8a1d20f3b"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Create the table holding evaluation reports of a run."""
+ op.create_table(
+ "report",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("run_id", sa.Integer(), nullable=False),
+ sa.Column("huey_id", sa.String(), nullable=True),
+ sa.Column("report_name", sa.String(), nullable=False),
+ sa.Column("parameters", sa.JSON(), nullable=True),
+ sa.Column("artifacts_path", sa.String(), nullable=True),
+ sa.Column("plot_overrides", sa.JSON(), nullable=True),
+ sa.Column("created", sa.DateTime(), nullable=True),
+ sa.Column(
+ "status",
+ sa.Enum(
+ "NOT_STARTED",
+ "DELIVERED",
+ "STARTED",
+ "FINISHED",
+ "ERROR",
+ name="reportstatus",
+ ),
+ nullable=False,
+ ),
+ sa.ForeignKeyConstraint(
+ ["run_id"],
+ ["run.id"],
+ name=op.f("fk_report_run_id_run"),
+ ondelete="CASCADE",
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_report")),
+ )
+
+
+def downgrade() -> None:
+ """Drop the report table."""
+ op.drop_table("report")
diff --git a/DashAI/back/api/api_v1/api.py b/DashAI/back/api/api_v1/api.py
index 3ae6829b3..b2642d59a 100644
--- a/DashAI/back/api/api_v1/api.py
+++ b/DashAI/back/api/api_v1/api.py
@@ -26,6 +26,7 @@
from DashAI.back.api.api_v1.endpoints.predict import router as predict
from DashAI.back.api.api_v1.endpoints.prompts import router as prompts
from DashAI.back.api.api_v1.endpoints.rag import router as rag
+from DashAI.back.api.api_v1.endpoints.reports import router as reports
from DashAI.back.api.api_v1.endpoints.runs import router as runs
from DashAI.back.api.api_v1.endpoints.statistical_tests import (
router as statistical_tests,
@@ -38,6 +39,7 @@
api_router_v1.include_router(documents, prefix="/document")
api_router_v1.include_router(model_sessions, prefix="/model-session")
api_router_v1.include_router(explainers, prefix="/explainer")
+api_router_v1.include_router(reports, prefix="/report")
api_router_v1.include_router(explorers, prefix="/explorer")
api_router_v1.include_router(jobs, prefix="/job")
api_router_v1.include_router(runs, prefix="/run")
diff --git a/DashAI/back/api/api_v1/endpoints/explainers.py b/DashAI/back/api/api_v1/endpoints/explainers.py
old mode 100644
new mode 100755
diff --git a/DashAI/back/api/api_v1/endpoints/reports.py b/DashAI/back/api/api_v1/endpoints/reports.py
new file mode 100644
index 000000000..655f00e67
--- /dev/null
+++ b/DashAI/back/api/api_v1/endpoints/reports.py
@@ -0,0 +1,316 @@
+"""REST endpoints for evaluation reports."""
+
+import logging
+from typing import TYPE_CHECKING
+
+from fastapi import APIRouter, Depends, status
+from fastapi.exceptions import HTTPException
+from kink import di, inject
+from sqlalchemy import exc, select
+
+from DashAI.back.api.api_v1.schemas.reports_params import ReportParams
+from DashAI.back.core.artifacts import (
+ PlotOverrideBody,
+ apply_plot_overrides,
+ normalize_artifacts,
+)
+from DashAI.back.dependencies.database.models import Report, Run
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import sessionmaker
+
+logging.basicConfig(level=logging.DEBUG)
+log = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.get("/")
+@inject
+async def get_reports(
+ run_id: int = None,
+ session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]),
+):
+ """Return the reports stored for a run.
+
+ Parameters
+ ----------
+ run_id : int, optional
+ Run whose reports are requested. All reports when omitted.
+ session_factory : Callable[..., ContextManager[Session]]
+ A factory that creates a context manager that handles a SQLAlchemy
+ session.
+
+ Returns
+ -------
+ List[Report]
+ The matching report rows.
+
+ Raises
+ ------
+ HTTPException
+ If the database cannot be read.
+ """
+ with session_factory() as db:
+ try:
+ statement = select(Report)
+ if run_id is not None:
+ statement = statement.where(Report.run_id == run_id)
+ return db.scalars(statement).all()
+ except exc.SQLAlchemyError as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal database error",
+ ) from e
+
+
+@router.get("/{report_id}/artifacts")
+@inject
+async def get_report_artifacts(
+ report_id: int,
+ session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]),
+):
+ """Return the computed artifacts of a report.
+
+ Parameters
+ ----------
+ report_id : int
+ Id of the report whose artifacts are requested.
+ session_factory : Callable[..., ContextManager[Session]]
+ A factory that creates a context manager that handles a SQLAlchemy
+ session.
+
+ Returns
+ -------
+ List[dict]
+ Artifact wire dicts, empty when the report has not run yet.
+
+ Raises
+ ------
+ HTTPException
+ If the report does not exist or its file cannot be read.
+ """
+ import pickle
+
+ with session_factory() as db:
+ try:
+ report = db.get(Report, report_id)
+ except exc.SQLAlchemyError as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal database error",
+ ) from e
+
+ if not report:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Report not found",
+ )
+
+ if not report.artifacts_path:
+ return []
+
+ try:
+ with open(report.artifacts_path, "rb") as file:
+ stored = pickle.load(file)
+ except OSError as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Report artifacts file not found",
+ ) from e
+
+ plot_overrides = report.plot_overrides
+
+ # Re-normalized on read for the same reason the explainer plot endpoints
+ # do it: artifacts pickled by an older version still come back current.
+ # Overrides are applied last so a user's saved edits win over the computed
+ # figure and survive a reload.
+ return apply_plot_overrides(normalize_artifacts(stored), plot_overrides)
+
+
+@router.post("/", status_code=status.HTTP_201_CREATED)
+@inject
+async def upload_report(
+ params: ReportParams,
+ session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]),
+):
+ """Create a report row for a run.
+
+ Parameters
+ ----------
+ params : ReportParams
+ Run id, report component name and parameters.
+ session_factory : Callable[..., ContextManager[Session]]
+ A factory that creates a context manager that handles a SQLAlchemy
+ session.
+
+ Returns
+ -------
+ Report
+ The created row.
+
+ Raises
+ ------
+ HTTPException
+ If the run does not exist or the row cannot be stored.
+ """
+ with session_factory() as db:
+ try:
+ run: Run = db.get(Run, params.run_id)
+ if not run:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Run not found"
+ )
+
+ report = Report(
+ run_id=params.run_id,
+ report_name=params.report_name,
+ parameters=params.parameters,
+ )
+ db.add(report)
+ db.commit()
+ db.refresh(report)
+ return report
+
+ except exc.SQLAlchemyError as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal database error",
+ ) from e
+
+
+@router.put("/{report_id}/override")
+@inject
+async def save_plot_override(
+ report_id: int,
+ body: PlotOverrideBody,
+ session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]),
+):
+ """Persist an edited plotly figure for one artifact of a report.
+
+ Parameters
+ ----------
+ report_id : int
+ Id of the report whose plot is being edited.
+ body : PlotOverrideBody
+ The artifact index and the edited plotly figure.
+ session_factory : Callable[..., ContextManager[Session]]
+ Factory yielding a SQLAlchemy session.
+
+ Returns
+ -------
+ dict
+ ``{"status": "ok"}`` on success.
+
+ Raises
+ ------
+ HTTPException
+ If the report does not exist.
+ """
+ import json
+
+ with session_factory() as db:
+ report = db.get(Report, report_id)
+ if report is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Report not found"
+ )
+ overrides = dict(report.plot_overrides or {})
+ figure = body.figure
+ overrides[str(body.index)] = (
+ figure if isinstance(figure, str) else json.dumps(figure)
+ )
+ report.plot_overrides = overrides
+ db.commit()
+ return {"status": "ok"}
+
+
+@router.delete("/{report_id}/override/{index}")
+@inject
+async def delete_plot_override(
+ report_id: int,
+ index: int,
+ session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]),
+):
+ """Remove a stored plot override, reverting to the computed figure.
+
+ Parameters
+ ----------
+ report_id : int
+ Id of the report.
+ index : int
+ Artifact index whose override is removed.
+ session_factory : Callable[..., ContextManager[Session]]
+ Factory yielding a SQLAlchemy session.
+
+ Returns
+ -------
+ dict
+ ``{"status": "ok"}``.
+
+ Raises
+ ------
+ HTTPException
+ If the report does not exist.
+ """
+ with session_factory() as db:
+ report = db.get(Report, report_id)
+ if report is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Report not found"
+ )
+ overrides = dict(report.plot_overrides or {})
+ overrides.pop(str(index), None)
+ report.plot_overrides = overrides or None
+ db.commit()
+ return {"status": "ok"}
+
+
+@router.delete("/{report_id}")
+@inject
+async def delete_report(
+ report_id: int,
+ session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]),
+):
+ """Delete a report and its stored artifacts.
+
+ Parameters
+ ----------
+ report_id : int
+ Id of the report to delete.
+ session_factory : Callable[..., ContextManager[Session]]
+ A factory that creates a context manager that handles a SQLAlchemy
+ session.
+
+ Raises
+ ------
+ HTTPException
+ If the report does not exist or the deletion fails.
+ """
+ import os
+
+ with session_factory() as db:
+ try:
+ report = db.get(Report, report_id)
+ if not report:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Report not found",
+ )
+
+ if report.artifacts_path and os.path.exists(report.artifacts_path):
+ os.remove(report.artifacts_path)
+
+ db.delete(report)
+ db.commit()
+
+ except exc.SQLAlchemyError as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal database error",
+ ) from e
diff --git a/DashAI/back/api/api_v1/endpoints/runs.py b/DashAI/back/api/api_v1/endpoints/runs.py
index 18c54ae04..fa847f3e8 100644
--- a/DashAI/back/api/api_v1/endpoints/runs.py
+++ b/DashAI/back/api/api_v1/endpoints/runs.py
@@ -16,6 +16,7 @@
Metric,
ModelSession,
Prediction,
+ Report,
Run,
RunStatus,
)
@@ -575,9 +576,13 @@ async def get_run_operations_count(
db.query(Prediction).filter(Prediction.run_id == run_id).count()
)
+ # Count reports
+ reports_count = db.query(Report).filter(Report.run_id == run_id).count()
+
return {
"explainers": global_explainers_count + local_explainers_count,
"predictions": predictions_count,
+ "reports": reports_count,
}
except exc.SQLAlchemyError as e:
log.exception(e)
@@ -627,8 +632,21 @@ async def delete_run_operations(
"global_explainers": 0,
"local_explainers": 0,
"predictions": 0,
+ "reports": 0,
}
+ # Delete reports: they describe the predictions of the fit
+ # being replaced, so a retrain must not leave them behind.
+ reports = db.query(Report).filter(Report.run_id == run_id).all()
+ for report in reports:
+ if report.artifacts_path and os.path.exists(report.artifacts_path):
+ try:
+ remove_path(report.artifacts_path)
+ except Exception as e:
+ log.warning(f"Failed to delete report file: {e}")
+ db.delete(report)
+ deleted_count["reports"] += 1
+
# Delete global explainers
global_explainers = (
db.query(GlobalExplainer).filter(GlobalExplainer.run_id == run_id).all()
diff --git a/DashAI/back/api/api_v1/schemas/job_params.py b/DashAI/back/api/api_v1/schemas/job_params.py
index 816e0f22e..1b4dee218 100644
--- a/DashAI/back/api/api_v1/schemas/job_params.py
+++ b/DashAI/back/api/api_v1/schemas/job_params.py
@@ -9,6 +9,7 @@ class JobParams(BaseModel):
job_type: Literal[
"ModelJob",
"ExplainerJob",
+ "ReportJob",
"PredictJob",
"DatasetJob",
"ExplorerJob",
diff --git a/DashAI/back/api/api_v1/schemas/reports_params.py b/DashAI/back/api/api_v1/schemas/reports_params.py
new file mode 100644
index 000000000..40e436a11
--- /dev/null
+++ b/DashAI/back/api/api_v1/schemas/reports_params.py
@@ -0,0 +1,14 @@
+from pydantic import BaseModel
+
+
+class ReportParams(BaseModel):
+ """Body of a report creation request.
+
+ A report covers every evaluation partition of the run, so it carries
+ neither a split nor a user supplied name: its component and its run
+ identify it.
+ """
+
+ run_id: int
+ report_name: str
+ parameters: dict = {}
diff --git a/DashAI/back/core/enums/status.py b/DashAI/back/core/enums/status.py
index a21973878..20ec6f22e 100644
--- a/DashAI/back/core/enums/status.py
+++ b/DashAI/back/core/enums/status.py
@@ -9,6 +9,14 @@ class ExplainerStatus(Enum):
ERROR = 4
+class ReportStatus(Enum):
+ NOT_STARTED = 0
+ DELIVERED = 1
+ STARTED = 2
+ FINISHED = 3
+ ERROR = 4
+
+
class RunStatus(Enum):
NOT_STARTED = 0
DELIVERED = 1
diff --git a/DashAI/back/dependencies/database/models.py b/DashAI/back/dependencies/database/models.py
index fa7b2fe6e..1b41d8d5e 100644
--- a/DashAI/back/dependencies/database/models.py
+++ b/DashAI/back/dependencies/database/models.py
@@ -30,6 +30,7 @@
ExplorerStatus,
PluginStatus,
PredictionStatus,
+ ReportStatus,
RunStatus,
)
@@ -384,6 +385,45 @@ def set_status_as_error(self) -> None:
self.status = ExplainerStatus.ERROR
+class Report(Base):
+ __tablename__ = "report"
+ """
+ Table to store an evaluation report of a run. One row covers every
+ evaluation partition; the artifacts carry one group per partition.
+ """
+ id: Mapped[int] = mapped_column(primary_key=True)
+ run_id: Mapped[int] = mapped_column(
+ ForeignKey("run.id", ondelete="CASCADE"), nullable=False
+ )
+ huey_id: Mapped[str] = mapped_column(String, nullable=True)
+ report_name: Mapped[str] = mapped_column(String, nullable=False)
+ parameters: Mapped[JSON] = mapped_column(JSON, nullable=True)
+ artifacts_path: Mapped[str] = mapped_column(String, nullable=True)
+ plot_overrides: Mapped[JSON] = mapped_column(JSON, nullable=True)
+ created: Mapped[DateTime] = mapped_column(
+ DateTime, default=datetime.now, nullable=True
+ )
+ status: Mapped[Enum] = mapped_column(
+ Enum(ReportStatus), nullable=False, default=ReportStatus.NOT_STARTED
+ )
+
+ def set_status_as_delivered(self) -> None:
+ """Update the status of the report to delivered."""
+ self.status = ReportStatus.DELIVERED
+
+ def set_status_as_started(self) -> None:
+ """Update the status of the report to started."""
+ self.status = ReportStatus.STARTED
+
+ def set_status_as_finished(self) -> None:
+ """Update the status of the report to finished."""
+ self.status = ReportStatus.FINISHED
+
+ def set_status_as_error(self) -> None:
+ """Update the status of the report to error."""
+ self.status = ReportStatus.ERROR
+
+
class LocalExplainer(Base):
__tablename__ = "local_explainer"
"""
diff --git a/DashAI/back/initial_components.py b/DashAI/back/initial_components.py
index 27bc9ed23..d2ed352c0 100644
--- a/DashAI/back/initial_components.py
+++ b/DashAI/back/initial_components.py
@@ -163,6 +163,7 @@
from DashAI.back.job.pipeline_job import PipelineJob
from DashAI.back.job.predict_job import PredictJob
from DashAI.back.job.RAG_job import RAGJob
+from DashAI.back.job.report_job import ReportJob
# Metrics
from DashAI.back.metrics.classification.accuracy import Accuracy
@@ -426,6 +427,31 @@
# Plugins
from DashAI.back.plugins.utils import get_available_plugins
+from DashAI.back.reports.classification.confusion_matrix import ConfusionMatrix
+
+# Reports
+from DashAI.back.reports.classification.per_class_breakdown import (
+ PerClassBreakdown,
+)
+from DashAI.back.reports.classification.precision_recall_curve import (
+ PrecisionRecallCurve,
+)
+from DashAI.back.reports.classification.roc_curve import RocCurve
+from DashAI.back.reports.forecasting.forecast_vs_actual import ForecastVsActual
+from DashAI.back.reports.forecasting.residual_autocorrelation import (
+ ResidualAutocorrelation,
+)
+from DashAI.back.reports.forecasting.residuals_over_time import ResidualsOverTime
+from DashAI.back.reports.regression.predicted_vs_actual import PredictedVsActual
+from DashAI.back.reports.regression.residual_histogram import ResidualHistogram
+from DashAI.back.reports.regression.residual_plot import ResidualPlot
+from DashAI.back.reports.translation.length_comparison import LengthComparison
+from DashAI.back.reports.translation.per_segment_comparison import (
+ PerSegmentComparison,
+)
+from DashAI.back.reports.translation.segment_score_distribution import (
+ SegmentScoreDistribution,
+)
from DashAI.back.splitters.group_k_fold import GroupKFoldSplitter
# Splitters
@@ -640,6 +666,7 @@ def get_initial_components():
DatafileJob,
ExplainerJob,
ModelJob,
+ ReportJob,
ExplorerJob,
PredictJob,
ConverterJob,
@@ -647,6 +674,20 @@ def get_initial_components():
GenerativeJob,
PipelineJob,
RAGJob,
+ # Reports
+ ConfusionMatrix,
+ RocCurve,
+ PrecisionRecallCurve,
+ PerClassBreakdown,
+ PredictedVsActual,
+ ResidualPlot,
+ ResidualHistogram,
+ ForecastVsActual,
+ ResidualsOverTime,
+ ResidualAutocorrelation,
+ PerSegmentComparison,
+ SegmentScoreDistribution,
+ LengthComparison,
# Explainers
ContrastiveShap,
DiceCounterfactual,
diff --git a/DashAI/back/job/report_job.py b/DashAI/back/job/report_job.py
new file mode 100644
index 000000000..7d2c05e94
--- /dev/null
+++ b/DashAI/back/job/report_job.py
@@ -0,0 +1,409 @@
+"""Job that turns a run's predictions into one report per evaluation partition.
+
+A report covers every partition the run exposes rather than one chosen at
+creation, and which partitions those are is decided by the splitter that
+produced the run rather than by this module: a holdout run yields train, test
+and validation, while a cross validated one yields the rows it reserved as a
+test set and the rest the final model was refit on. A task that predicts
+forward only, such as forecasting, can never be scored on the rows its model
+was fitted on, so those partitions are dropped exactly as the prediction flow
+drops them: a report must not ask a model for a value it refuses to give. The
+set is read through the same helpers the prediction and local explainer flows
+use, so a report can never cover a different set than the one the user was
+offered. No report class changes to support a new splitter, because none of
+them know what a partition is.
+"""
+
+import logging
+from typing import TYPE_CHECKING, List, Optional
+
+from kink import inject
+from sqlalchemy import exc
+
+from DashAI.back.dependencies.database.models import (
+ Dataset,
+ ModelSession,
+ Report,
+ Run,
+)
+from DashAI.back.job.base_job import BaseJob, JobError
+from DashAI.back.models.base_model import BaseModel
+from DashAI.back.splitters.splits_payload import predictable_splits, run_split_indexes
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import sessionmaker
+
+logging.basicConfig(level=logging.DEBUG)
+log = logging.getLogger(__name__)
+
+
+PARTITION_LABELS = {
+ "train": "Train",
+ "test": "Test",
+ "val": "Validation",
+ "validation": "Validation",
+ "all": "Whole dataset",
+}
+
+
+def partition_label(name: str) -> str:
+ """Build the selector entry title for one partition.
+
+ Names outside :data:`PARTITION_LABELS` fall back to their own titled form,
+ so a splitter added later needs no change here.
+
+ Parameters
+ ----------
+ name : str
+ Partition name as reported by the run's splitter.
+
+ Returns
+ -------
+ str
+ The mapped label, or the name in title case when it is not mapped.
+ """
+ return PARTITION_LABELS.get(name, name.replace("_", " ").title())
+
+
+class ReportJob(BaseJob):
+ """Compute one evaluation report for a run over one split.
+
+ Rebuilds the requested split, predicts with the trained model, and hands
+ the truth and the predictions to the report. The model's inputs are
+ never passed on: a report compares predictions against the truth and
+ nothing else, which is what separates it from an explainer.
+ """
+
+ @inject
+ def set_status_as_delivered(
+ self, session_factory: "sessionmaker" = lambda di: di["session_factory"]
+ ) -> None:
+ """Mark the report as queued.
+
+ Parameters
+ ----------
+ session_factory : sessionmaker
+ Factory producing a SQLAlchemy session.
+
+ Raises
+ ------
+ JobError
+ If the report does not exist or the database rejects the update.
+ """
+ report_id: int = self.kwargs["report_id"]
+ with session_factory() as db:
+ report: Report = db.get(Report, report_id)
+ if not report:
+ raise JobError(f"Report with id {report_id} does not exist in DB.")
+ try:
+ report.set_status_as_delivered()
+ db.commit()
+ except exc.SQLAlchemyError as e:
+ log.exception(e)
+ raise JobError("Internal database error") from e
+
+ @inject
+ def set_status_as_error(
+ self, session_factory: "sessionmaker" = lambda di: di["session_factory"]
+ ) -> None:
+ """Mark the report as failed.
+
+ Parameters
+ ----------
+ session_factory : sessionmaker
+ Factory producing a SQLAlchemy session.
+ """
+ report_id: Optional[int] = self.kwargs.get("report_id")
+ if report_id is None:
+ return
+ with session_factory() as db:
+ try:
+ report: Report = db.get(Report, report_id)
+ if report:
+ report.set_status_as_error()
+ db.commit()
+ except exc.SQLAlchemyError as e:
+ log.exception(e)
+
+ def get_job_name(self) -> str:
+ """Build a descriptive name for this job.
+
+ Returns
+ -------
+ str
+ The report's name when it can be read, a generic label
+ otherwise.
+ """
+ report_id = self.kwargs.get("report_id")
+ if not report_id:
+ return "Report"
+
+ from kink import di
+
+ try:
+ with di["session_factory"]() as db:
+ report: Report = db.get(Report, report_id)
+ if report:
+ return f"Report: {report.report_name}"
+ except Exception:
+ pass
+ return f"Report ({report_id})"
+
+ @staticmethod
+ def _class_names(model: BaseModel) -> Optional[List[str]]:
+ """Recover the human readable class labels of a fitted classifier.
+
+ Parameters
+ ----------
+ model : BaseModel
+ The trained model, which may carry output encodings.
+
+ Returns
+ -------
+ Optional[List[str]]
+ Class labels in encoded order, or None for a regressor.
+ """
+ encodings = getattr(model, "output_encodings", None)
+ mapping = next(iter(encodings.values()), None) if encodings else None
+ if mapping:
+ return [
+ str(label) for label, _ in sorted(mapping.items(), key=lambda p: p[1])
+ ]
+ classes = getattr(model, "classes_", None)
+ if classes is None:
+ return None
+ return [str(label) for label in classes]
+
+ @inject
+ def run(self) -> None:
+ """Compute and persist the report's artifacts.
+
+ The dataset is prepared once over every row and then indexed per
+ partition, because the row indexes a splitter reports are indexes into
+ the dataset as stored. Inputs reach the model unprepared, exactly as
+ the prediction job feeds them, since the model applies its own
+ preprocessing; targets are encoded so they line up with the class
+ indexes the model predicts.
+
+ A partition that the report cannot describe, such as one holding a
+ single class, is skipped and named in the saved output rather than
+ costing the user the partitions that did compute. The job only fails
+ when no partition produced anything.
+
+ Raises
+ ------
+ JobError
+ If any stage of the reconstruction or computation fails.
+ """
+ import os
+ import pickle
+
+ from kink import di
+
+ from DashAI.back.core.artifacts import (
+ ArtifactGroup,
+ GroupedArtifacts,
+ TextArtifact,
+ normalize_artifacts,
+ )
+ from DashAI.back.dataloaders.classes.dashai_dataset import (
+ load_dataset,
+ select_columns,
+ )
+ from DashAI.back.reports.base_report import ReportError
+ from DashAI.back.tasks.base_task import BaseTask
+
+ component_registry = di["component_registry"]
+ session_factory = di["session_factory"]
+ config = di["config"]
+ report_id: int = self.kwargs["report_id"]
+
+ with session_factory() as db:
+ report: Report = db.get(Report, report_id)
+ if not report:
+ raise JobError(f"Report with id {report_id} does not exist in DB.")
+
+ try:
+ run: Run = db.get(Run, report.run_id)
+ if not run:
+ raise JobError(f"Run {report.run_id} does not exist in DB.")
+ model_session: ModelSession = db.get(ModelSession, run.model_session_id)
+ if not model_session:
+ raise JobError(
+ f"Model session {run.model_session_id} does not exist in DB."
+ )
+ dataset: Dataset = db.get(Dataset, model_session.dataset_id)
+ if not dataset:
+ raise JobError(
+ f"Dataset {model_session.dataset_id} does not exist in DB."
+ )
+
+ try:
+ report.set_status_as_started()
+ db.commit()
+ except exc.SQLAlchemyError as e:
+ log.exception(e)
+ raise JobError("Connection with the database failed") from e
+
+ try:
+ model_class = component_registry[run.model_name]["class"]
+ model: BaseModel = model_class(**run.parameters)
+ trained_model = model.load(run.run_path)
+ except Exception as e:
+ log.exception(e)
+ raise JobError(
+ f"Can not load model {run.model_name} from {run.run_path}"
+ ) from e
+
+ try:
+ report_class = component_registry[report.report_name]["class"]
+ instance = report_class(**(report.parameters or {}))
+ except Exception as e:
+ log.exception(e)
+ raise JobError(
+ f"Unable to instantiate report {report.report_name}."
+ ) from e
+
+ self.report_progress(0.3, "Resolving the run's partitions")
+ try:
+ splits = predictable_splits(
+ model_session.splits,
+ run.split_indexes,
+ component_registry,
+ task_name=model_session.task_name,
+ evaluation_strategy=model_session.evaluation_strategy,
+ )
+ except ValueError as e:
+ log.exception(e)
+ raise JobError(str(e)) from e
+
+ if not splits:
+ raise JobError(
+ "The run has no partition a report can be computed on: "
+ "every row went into fitting the model."
+ )
+
+ try:
+ partition_indexes = {
+ split["name"]: run_split_indexes(
+ model_session.splits,
+ run.split_indexes,
+ component_registry,
+ split["name"],
+ )
+ for split in splits
+ }
+ except ValueError as e:
+ log.exception(e)
+ raise JobError(str(e)) from e
+
+ try:
+ loaded_dataset = load_dataset(f"{dataset.file_path}/dataset")
+ task: BaseTask = component_registry[model_session.task_name][
+ "class"
+ ]()
+ prepared_dataset = task.prepare_for_task(
+ dataset=loaded_dataset,
+ input_columns=model_session.input_columns,
+ output_columns=model_session.output_columns,
+ )
+ data_x, data_y = select_columns(
+ prepared_dataset,
+ model_session.input_columns,
+ model_session.output_columns,
+ )
+ except Exception as e:
+ log.exception(e)
+ raise JobError(
+ f"Can not prepare dataset {dataset.id} for the report"
+ ) from e
+
+ self.report_progress(0.6, "Computing every partition")
+ class_names = self._class_names(trained_model)
+ groups = []
+ skipped = []
+ last_error = None
+
+ for partition, row_indexes in partition_indexes.items():
+ partition_x = (
+ data_x if row_indexes is None else data_x.select(row_indexes)
+ )
+ partition_y = (
+ data_y if row_indexes is None else data_y.select(row_indexes)
+ )
+ if partition_x.num_rows == 0:
+ continue
+ try:
+ y_pred = trained_model.predict(partition_x)
+ y_true = (
+ trained_model.prepare_output(partition_y, is_fit=False)
+ .to_pandas()
+ .to_numpy()
+ .ravel()
+ )
+ except Exception as e:
+ log.exception(e)
+ raise JobError(
+ f"Failed to predict the {partition} partition"
+ ) from e
+
+ try:
+ leaves = instance.compute(y_true, y_pred, class_names)
+ except ReportError as e:
+ log.warning("Skipping %s partition: %s", partition, e)
+ skipped.append(f"{partition_label(partition)}: {e}")
+ last_error = e
+ continue
+ except Exception as e:
+ log.exception(e)
+ raise JobError("Failed to compute the report") from e
+
+ if leaves:
+ groups.append(
+ ArtifactGroup(
+ title=partition_label(partition),
+ artifacts=leaves,
+ )
+ )
+
+ if not groups:
+ raise JobError(
+ str(last_error)
+ if last_error
+ else "The report produced no output for any partition."
+ )
+
+ self.report_progress(0.8, "Saving the report")
+ items = [GroupedArtifacts(title=None, groups=groups)]
+ if skipped:
+ items.append(
+ TextArtifact(
+ payload="\n".join(skipped),
+ title="Partitions not shown",
+ )
+ )
+ artifacts = normalize_artifacts(items)
+
+ try:
+ path = os.path.join(
+ config["RUNS_PATH"], f"report_{report_id}.pickle"
+ )
+ with open(path, "wb") as file:
+ pickle.dump(artifacts, file)
+ except Exception as e:
+ log.exception(e)
+ raise JobError("Report file saving failed") from e
+
+ try:
+ report.artifacts_path = path
+ report.plot_overrides = None
+ report.set_status_as_finished()
+ db.commit()
+ except Exception as e:
+ log.exception(e)
+ raise JobError("Report path saving failed") from e
+
+ except Exception as e:
+ report.set_status_as_error()
+ db.commit()
+ raise e
diff --git a/DashAI/back/reports/__init__.py b/DashAI/back/reports/__init__.py
new file mode 100644
index 000000000..9c0fa90a1
--- /dev/null
+++ b/DashAI/back/reports/__init__.py
@@ -0,0 +1 @@
+# flake8: noqa
diff --git a/DashAI/back/reports/base_report.py b/DashAI/back/reports/base_report.py
new file mode 100644
index 000000000..d8657cd9f
--- /dev/null
+++ b/DashAI/back/reports/base_report.py
@@ -0,0 +1,148 @@
+"""Base Report abstract class."""
+
+from abc import ABC, abstractmethod
+from typing import TYPE_CHECKING, Any, Dict, Final, List, Optional
+
+from DashAI.back.config_object import ConfigObject
+from DashAI.back.core.artifacts import Artifact
+
+if TYPE_CHECKING:
+ from numpy import ndarray
+
+
+class ReportError(Exception):
+ """Raised when a report cannot be computed from the inputs it got."""
+
+
+class BaseReport(ConfigObject, ABC):
+ """Abstract base class for evaluation reports.
+
+ A report is the artifact-valued sibling of a metric. Both are computed
+ from the same inputs at the same moment, comparing a model's predictions
+ over a split against the truth; they differ only in codomain::
+
+ Metric: (y_true, y_pred) -> float (rankable, optimizable)
+ Report: (y_true, y_pred) -> artifact (structured, not rankable)
+
+ That is why a confusion matrix or an ROC curve cannot be a ``Metric``: a
+ K x K grid does not fit a float column, cannot be ranked for model
+ selection and cannot be handed to a hyperparameter optimizer. The scalar
+ summaries of those shapes (accuracy, ROC AUC) already exist as metrics; a
+ report renders the shape those numbers condense.
+
+ A report is also not an explainer. An explainer probes how the model
+ responds to *features*; a report never looks at the inputs at all, only
+ at predictions against the truth.
+
+ Class attributes
+ ----------------
+ TYPE : str
+ Always ``"Report"``; used by the DashAI component registry.
+ REQUIRES_PROBABILITIES : bool
+ ``True`` when ``compute`` needs the full class probability matrix
+ rather than hard labels. The job checks the actual prediction shape
+ against this before running, so an incompatible model fails with a
+ clear message instead of a shape error deep inside a plot call.
+ DISPLAY_NAME, DESCRIPTION, COLOR, ICON
+ UI metadata, matching the conventions used by models and explainers.
+ """
+
+ TYPE: Final[str] = "Report"
+ REQUIRES_PROBABILITIES: bool = False
+ DISPLAY_NAME: str = ""
+ DESCRIPTION: str = ""
+ COLOR: str = "#5C6BC0"
+ ICON: str = "Insights"
+
+ @classmethod
+ def get_metadata(cls) -> Dict[str, Any]:
+ """Get metadata values for the current report.
+
+ Returns
+ -------
+ Dict[str, Any]
+ UI metadata, including whether the report needs a model that
+ outputs class probabilities.
+ """
+ metadata: Dict[str, Any] = {}
+ metadata["icon"] = cls.ICON if cls.ICON else "Insights"
+ metadata["requires_probabilities"] = cls.REQUIRES_PROBABILITIES
+ return metadata
+
+ @abstractmethod
+ def compute(
+ self,
+ y_true: "ndarray",
+ y_pred: "ndarray",
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build renderable artifacts comparing predictions against the truth.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Ground truth for the split, encoded the way the model was trained:
+ class indexes for classification, raw values for regression.
+ y_pred : ndarray
+ What the model's ``predict`` returned for the same rows. DashAI
+ classifiers return a ``(n_samples, n_classes)`` probability matrix
+ and regressors return a 1D array of values, so a report that
+ needs hard labels takes the argmax itself.
+ class_names : Optional[List[str]]
+ Class labels in encoded order, or None for regression.
+
+ Returns
+ -------
+ List[Artifact]
+ The artifacts to render, in display order. Leaves only: the job
+ calls this once per evaluation partition and wraps each result in
+ a group, and a group cannot contain another group.
+
+ Raises
+ ------
+ NotImplementedError
+ If the subclass does not provide an implementation.
+ """
+ raise NotImplementedError
+
+
+def as_labels(y_pred: "ndarray") -> "ndarray":
+ """Reduce a prediction array to hard class labels.
+
+ Parameters
+ ----------
+ y_pred : ndarray
+ Either a ``(n_samples, n_classes)`` probability matrix or a 1D array of
+ labels.
+
+ Returns
+ -------
+ ndarray
+ A 1D array of class indexes.
+ """
+ import numpy as np
+
+ predictions = np.asarray(y_pred)
+ return predictions.argmax(axis=1) if predictions.ndim == 2 else predictions
+
+
+def resolve_class_names(class_names: Optional[List[str]], n_classes: int) -> List[str]:
+ """Fill in class labels when the run carries none.
+
+ Parameters
+ ----------
+ class_names : Optional[List[str]]
+ Labels in encoded order, possibly None or shorter than ``n_classes``.
+ n_classes : int
+ How many classes the model predicts.
+
+ Returns
+ -------
+ List[str]
+ Exactly ``n_classes`` labels, falling back to the class index.
+ """
+ names = list(class_names or [])
+ return [
+ str(names[index]) if index < len(names) else str(index)
+ for index in range(n_classes)
+ ]
diff --git a/DashAI/back/reports/classification/__init__.py b/DashAI/back/reports/classification/__init__.py
new file mode 100644
index 000000000..9c0fa90a1
--- /dev/null
+++ b/DashAI/back/reports/classification/__init__.py
@@ -0,0 +1 @@
+# flake8: noqa
diff --git a/DashAI/back/reports/classification/confusion_matrix.py b/DashAI/back/reports/classification/confusion_matrix.py
new file mode 100644
index 000000000..7f657029f
--- /dev/null
+++ b/DashAI/back/reports/classification/confusion_matrix.py
@@ -0,0 +1,189 @@
+"""Confusion matrix report."""
+
+from typing import List, Optional
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.schema_fields import BaseSchema, enum_field, schema_field
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import (
+ BaseReport,
+ as_labels,
+ resolve_class_names,
+)
+
+
+class ConfusionMatrixSchema(BaseSchema):
+ """Schema that configures the confusion matrix."""
+
+ normalize: schema_field(
+ enum_field(enum=["none", "true", "pred"]),
+ placeholder="none",
+ description=MultilingualString(
+ en=(
+ "How to normalise the counts. 'none' shows raw counts, 'true' "
+ "divides each row by its true class total (recall per class), "
+ "'pred' divides each column by its predicted total (precision "
+ "per class)."
+ ),
+ es=(
+ "Cómo normalizar los conteos. 'none' muestra conteos crudos, "
+ "'true' divide cada fila por el total de su clase real "
+ "(exhaustividad por clase), 'pred' divide cada columna por su "
+ "total predicho (precisión por clase)."
+ ),
+ pt=(
+ "Como normalizar as contagens. 'none' mostra contagens brutas, "
+ "'true' divide cada linha pelo total da sua classe real "
+ "(revocação por classe), 'pred' divide cada coluna pelo seu "
+ "total previsto (precisão por classe)."
+ ),
+ de=(
+ "Wie die Zählungen normalisiert werden. 'none' zeigt Rohwerte, "
+ "'true' teilt jede Zeile durch die Gesamtzahl ihrer echten "
+ "Klasse (Trefferquote je Klasse), 'pred' teilt jede Spalte "
+ "durch ihre vorhergesagte Gesamtzahl (Genauigkeit je Klasse)."
+ ),
+ zh=(
+ "如何归一化计数。'none' 显示原始计数,'true' 将每行除以其真实"
+ "类别总数(每类召回率),'pred' 将每列除以其预测总数"
+ "(每类精确率)。"
+ ),
+ ),
+ alias=MultilingualString(
+ en="Normalize",
+ es="Normalizar",
+ pt="Normalizar",
+ de="Normalisieren",
+ zh="归一化",
+ ),
+ ) # type: ignore
+
+
+class ConfusionMatrix(BaseReport):
+ """K x K grid of true class against predicted class.
+
+ Reading the grid tells you *which* classes a model confuses, which the
+ scalar accuracy summarising it cannot: two models with identical accuracy
+ can fail in completely different places. Off diagonal mass concentrated in
+ one cell means a systematic confusion between that pair of classes; mass
+ spread evenly across a row means the model has no signal for that class.
+
+ References
+ ----------
+ - [1] https://scikit-learn.org/stable/modules/generated/sklearn.metrics.confusion_matrix.html
+ """
+
+ SCHEMA = ConfusionMatrixSchema
+ COMPATIBLE_COMPONENTS = [
+ "TabularClassificationTask",
+ "TextClassificationTask",
+ "ImageClassificationTask",
+ ]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Confusion Matrix",
+ es="Matriz de Confusión",
+ pt="Matriz de Confusão",
+ de="Konfusionsmatrix",
+ zh="混淆矩阵",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Which classes the model confuses, as a true against predicted grid.",
+ es=("Qué clases confunde el modelo, como una grilla de real contra predicho."),
+ pt=("Quais classes o modelo confunde, como uma grade de real contra previsto."),
+ de=(
+ "Welche Klassen das Modell verwechselt, als Gitter aus echt gegen "
+ "vorhergesagt."
+ ),
+ zh="模型混淆了哪些类别,以真实类别与预测类别的网格呈现。",
+ )
+ COLOR: str = "#5C6BC0"
+ ICON: str = "GridOn"
+
+ def __init__(self, normalize: str = "none", **kwargs) -> None:
+ """Initialise the report.
+
+ Parameters
+ ----------
+ normalize : str
+ One of ``"none"``, ``"true"`` or ``"pred"``.
+ **kwargs : dict
+ Ignored; accepted so unknown stored parameters do not break loading.
+ """
+ self.normalize = normalize
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the confusion matrix heatmap.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Encoded true class indexes.
+ y_pred : ndarray
+ Model predictions, probabilities or hard labels.
+ class_names : Optional[List[str]]
+ Class labels in encoded order.
+
+ Returns
+ -------
+ List[Artifact]
+ A single heatmap artifact.
+ """
+ import numpy as np
+ import plotly.graph_objects as go
+ from sklearn.metrics import confusion_matrix
+
+ labels = as_labels(y_pred)
+ truth = np.asarray(y_true).ravel()
+ n_classes = int(max(truth.max(), labels.max())) + 1
+ names = resolve_class_names(class_names, n_classes)
+
+ matrix = confusion_matrix(truth, labels, labels=list(range(n_classes))).astype(
+ float
+ )
+
+ if self.normalize == "true":
+ totals = matrix.sum(axis=1, keepdims=True)
+ title_suffix = " (row normalized)"
+ elif self.normalize == "pred":
+ totals = matrix.sum(axis=0, keepdims=True)
+ title_suffix = " (column normalized)"
+ else:
+ totals = None
+ title_suffix = ""
+
+ if totals is not None:
+ # An unpredicted class leaves a zero total; leave those cells at 0
+ # rather than emitting NaN, which plotly renders as a hole.
+ with np.errstate(divide="ignore", invalid="ignore"):
+ matrix = np.divide(
+ matrix, totals, out=np.zeros_like(matrix), where=totals != 0
+ )
+
+ text_format = "{:.0f}" if totals is None else "{:.2f}"
+ figure = go.Figure(
+ go.Heatmap(
+ z=matrix.tolist(),
+ x=names,
+ y=names,
+ colorscale="Blues",
+ text=[[text_format.format(value) for value in row] for row in matrix],
+ texttemplate="%{text}",
+ hovertemplate=(
+ "true: %{y} predicted: %{x} value: %{z}"
+ ),
+ )
+ )
+ figure.update_layout(
+ title=f"Confusion matrix{title_suffix}",
+ xaxis_title="Predicted",
+ yaxis_title="True",
+ # Read top-left to bottom-right like the printed convention.
+ yaxis={"autorange": "reversed"},
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="Confusion matrix")]
diff --git a/DashAI/back/reports/classification/per_class_breakdown.py b/DashAI/back/reports/classification/per_class_breakdown.py
new file mode 100644
index 000000000..b80c32242
--- /dev/null
+++ b/DashAI/back/reports/classification/per_class_breakdown.py
@@ -0,0 +1,124 @@
+"""Per class precision, recall, F1 and support as a table."""
+
+from typing import List, Optional
+
+from DashAI.back.core.artifacts import (
+ Artifact,
+ TableArtifact,
+ TablePayload,
+)
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import (
+ BaseReport,
+ as_labels,
+ resolve_class_names,
+)
+
+
+class PerClassBreakdown(BaseReport):
+ """Precision, recall, F1 and support broken down per class.
+
+ The aggregate precision and recall metrics average over classes and so hide
+ the case that matters most: a model that scores well overall while being
+ useless on a small class. This table is that breakdown, with support
+ included so a weak row can be read against how many samples back it.
+
+ References
+ ----------
+ - [1] https://scikit-learn.org/stable/modules/generated/sklearn.metrics.per_class_breakdown.html
+ """
+
+ COMPATIBLE_COMPONENTS = [
+ "TabularClassificationTask",
+ "TextClassificationTask",
+ "ImageClassificationTask",
+ ]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Per Class Breakdown",
+ es="Desglose por Clase",
+ pt="Detalhamento por Classe",
+ de="Aufschlüsselung je Klasse",
+ zh="分类别明细",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Precision, recall, F1 and support for every class.",
+ es="Precisión, exhaustividad, F1 y soporte para cada clase.",
+ pt="Precisão, revocação, F1 e suporte para cada classe.",
+ de="Genauigkeit, Trefferquote, F1 und Support für jede Klasse.",
+ zh="每个类别的精确率、召回率、F1 和支持度。",
+ )
+ COLOR: str = "#66BB6A"
+ ICON: str = "TableChart"
+
+ def __init__(self, **kwargs) -> None:
+ """Initialise the report. It takes no parameters."""
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the per class report table.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Encoded true class indexes.
+ y_pred : ndarray
+ Model predictions, probabilities or hard labels.
+ class_names : Optional[List[str]]
+ Class labels in encoded order.
+
+ Returns
+ -------
+ List[Artifact]
+ A single table artifact, one row per class plus the averages.
+ """
+ import numpy as np
+ from sklearn.metrics import precision_recall_fscore_support
+
+ labels = as_labels(y_pred)
+ truth = np.asarray(y_true).ravel()
+ n_classes = int(max(truth.max(), labels.max())) + 1
+ names = resolve_class_names(class_names, n_classes)
+ indexes = list(range(n_classes))
+
+ precision, recall, f1, support = precision_recall_fscore_support(
+ truth, labels, labels=indexes, zero_division=0
+ )
+
+ rows = [
+ [
+ names[index],
+ round(float(precision[index]), 4),
+ round(float(recall[index]), 4),
+ round(float(f1[index]), 4),
+ int(support[index]),
+ ]
+ for index in indexes
+ ]
+
+ for average in ("macro", "weighted"):
+ avg_precision, avg_recall, avg_f1, _ = precision_recall_fscore_support(
+ truth, labels, labels=indexes, average=average, zero_division=0
+ )
+ rows.append(
+ [
+ f"{average} avg",
+ round(float(avg_precision), 4),
+ round(float(avg_recall), 4),
+ round(float(avg_f1), 4),
+ int(support.sum()),
+ ]
+ )
+
+ return [
+ TableArtifact(
+ payload=TablePayload(
+ columns=["Class", "Precision", "Recall", "F1", "Support"],
+ rows=rows,
+ ),
+ title="Per class breakdown",
+ )
+ ]
diff --git a/DashAI/back/reports/classification/precision_recall_curve.py b/DashAI/back/reports/classification/precision_recall_curve.py
new file mode 100644
index 000000000..0f47fc97a
--- /dev/null
+++ b/DashAI/back/reports/classification/precision_recall_curve.py
@@ -0,0 +1,130 @@
+"""Precision recall curve report."""
+
+from typing import List, Optional
+
+import numpy as np
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import (
+ BaseReport,
+ ReportError,
+ resolve_class_names,
+)
+from DashAI.back.reports.classification.roc_curve import probability_matrix
+
+
+class PrecisionRecallCurve(BaseReport):
+ """One vs rest precision against recall, with average precision annotated.
+
+ Preferred over ROC under class imbalance: the false positive rate that ROC
+ plots is divided by the (large) number of true negatives, so a rare-positive
+ problem can show an excellent ROC curve while the model is mostly wrong
+ whenever it does predict the positive class. Precision has no such
+ denominator, so this curve stays honest as the classes skew.
+
+ References
+ ----------
+ - [1] https://scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_recall_curve.html
+ """
+
+ REQUIRES_PROBABILITIES: bool = True
+ COMPATIBLE_COMPONENTS = [
+ "TabularClassificationTask",
+ "TextClassificationTask",
+ "ImageClassificationTask",
+ ]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Precision Recall Curve",
+ es="Curva Precisión-Exhaustividad",
+ pt="Curva Precisão-Revocação",
+ de="Precision-Recall-Kurve",
+ zh="精确率召回率曲线",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en=(
+ "Precision against recall per class; more honest than ROC when "
+ "classes are imbalanced."
+ ),
+ es=(
+ "Precisión contra exhaustividad por clase; más honesta que ROC "
+ "cuando las clases están desbalanceadas."
+ ),
+ pt=(
+ "Precisão contra revocação por classe; mais honesta que ROC quando "
+ "as classes estão desbalanceadas."
+ ),
+ de=(
+ "Genauigkeit gegen Trefferquote je Klasse; ehrlicher als ROC bei "
+ "unausgeglichenen Klassen."
+ ),
+ zh="每个类别的精确率与召回率;在类别不平衡时比 ROC 更可靠。",
+ )
+ COLOR: str = "#AB47BC"
+ ICON: str = "Timeline"
+
+ def __init__(self, **kwargs) -> None:
+ """Initialise the report. It takes no parameters."""
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the one vs rest precision recall curves.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Encoded true class indexes.
+ y_pred : ndarray
+ Class probability matrix.
+ class_names : Optional[List[str]]
+ Class labels in encoded order.
+
+ Returns
+ -------
+ List[Artifact]
+ A single figure holding one curve per class.
+ """
+ import plotly.graph_objects as go
+ from sklearn.metrics import average_precision_score, precision_recall_curve
+
+ probabilities = probability_matrix(y_pred)
+ truth = np.asarray(y_true).ravel()
+ names = resolve_class_names(class_names, probabilities.shape[1])
+
+ figure = go.Figure()
+ target_classes = [1] if probabilities.shape[1] == 2 else range(len(names))
+ for class_index in target_classes:
+ positives = (truth == class_index).astype(int)
+ if positives.sum() == 0 or positives.sum() == len(positives):
+ continue
+ precision, recall, _ = precision_recall_curve(
+ positives, probabilities[:, class_index]
+ )
+ average = average_precision_score(positives, probabilities[:, class_index])
+ figure.add_trace(
+ go.Scatter(
+ x=recall.tolist(),
+ y=precision.tolist(),
+ mode="lines",
+ name=f"{names[class_index]} (AP {average:.3f})",
+ line={"width": 2},
+ )
+ )
+
+ if not figure.data:
+ raise ReportError(
+ "The split holds a single class, so no precision recall curve "
+ "is defined."
+ )
+
+ figure.update_layout(
+ title="Precision recall curve (one vs rest)",
+ xaxis_title="Recall",
+ yaxis_title="Precision",
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="Precision recall curve")]
diff --git a/DashAI/back/reports/classification/roc_curve.py b/DashAI/back/reports/classification/roc_curve.py
new file mode 100644
index 000000000..2b972d9d6
--- /dev/null
+++ b/DashAI/back/reports/classification/roc_curve.py
@@ -0,0 +1,161 @@
+"""ROC curve report."""
+
+from typing import List, Optional
+
+import numpy as np
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import (
+ BaseReport,
+ ReportError,
+ resolve_class_names,
+)
+
+
+def probability_matrix(y_pred) -> "np.ndarray":
+ """Return the prediction as a probability matrix, or fail loudly.
+
+ Parameters
+ ----------
+ y_pred : ndarray
+ What the model's ``predict`` returned.
+
+ Returns
+ -------
+ np.ndarray
+ A ``(n_samples, n_classes)`` matrix.
+
+ Raises
+ ------
+ ReportError
+ If the model returned hard labels, which carry no ranking information
+ and so cannot produce a curve.
+ """
+ predictions = np.asarray(y_pred, dtype=float)
+ if predictions.ndim != 2 or predictions.shape[1] < 2:
+ raise ReportError(
+ "This report needs class probabilities, but the model returned "
+ "hard labels. Pick a model that outputs probabilities."
+ )
+ return predictions
+
+
+class RocCurve(BaseReport):
+ """One vs rest ROC curve per class, with the AUC annotated.
+
+ ROC AUC already exists as a metric because it is a single number. The curve
+ is the shape that number condenses: it shows *where* along the operating
+ range the model trades false positives for true positives, so two models
+ with equal AUC can be told apart by which end of the range they are good at.
+
+ References
+ ----------
+ - [1] https://scikit-learn.org/stable/modules/generated/sklearn.metrics.roc_curve.html
+ """
+
+ REQUIRES_PROBABILITIES: bool = True
+ COMPATIBLE_COMPONENTS = [
+ "TabularClassificationTask",
+ "TextClassificationTask",
+ "ImageClassificationTask",
+ ]
+ DISPLAY_NAME: str = MultilingualString(
+ en="ROC Curve",
+ es="Curva ROC",
+ pt="Curva ROC",
+ de="ROC-Kurve",
+ zh="ROC 曲线",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="True positive rate against false positive rate, one curve per class.",
+ es=(
+ "Tasa de verdaderos positivos contra falsos positivos, una curva por clase."
+ ),
+ pt=(
+ "Taxa de verdadeiros positivos contra falsos positivos, uma curva "
+ "por classe."
+ ),
+ de=("Richtig-Positiv-Rate gegen Falsch-Positiv-Rate, eine Kurve je Klasse."),
+ zh="真正例率与假正例率的关系,每个类别一条曲线。",
+ )
+ COLOR: str = "#26A69A"
+ ICON: str = "ShowChart"
+
+ def __init__(self, **kwargs) -> None:
+ """Initialise the report. It takes no parameters."""
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the one vs rest ROC curves.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Encoded true class indexes.
+ y_pred : ndarray
+ Class probability matrix.
+ class_names : Optional[List[str]]
+ Class labels in encoded order.
+
+ Returns
+ -------
+ List[Artifact]
+ A single figure holding one curve per class plus the chance line.
+ """
+ import plotly.graph_objects as go
+ from sklearn.metrics import auc, roc_curve
+
+ probabilities = probability_matrix(y_pred)
+ truth = np.asarray(y_true).ravel()
+ names = resolve_class_names(class_names, probabilities.shape[1])
+
+ figure = go.Figure()
+ figure.add_trace(
+ go.Scatter(
+ x=[0, 1],
+ y=[0, 1],
+ mode="lines",
+ name="Chance",
+ line={"dash": "dash", "width": 1, "color": "#9e9e9e"},
+ hoverinfo="skip",
+ )
+ )
+
+ # A binary problem is one curve, not two mirror images of each other.
+ target_classes = [1] if probabilities.shape[1] == 2 else range(len(names))
+ for class_index in target_classes:
+ positives = (truth == class_index).astype(int)
+ if positives.sum() == 0 or positives.sum() == len(positives):
+ # Only one class present in this split: the curve is undefined.
+ continue
+ false_positive, true_positive, _ = roc_curve(
+ positives, probabilities[:, class_index]
+ )
+ area = auc(false_positive, true_positive)
+ figure.add_trace(
+ go.Scatter(
+ x=false_positive.tolist(),
+ y=true_positive.tolist(),
+ mode="lines",
+ name=f"{names[class_index]} (AUC {area:.3f})",
+ line={"width": 2},
+ )
+ )
+
+ if len(figure.data) == 1:
+ raise ReportError(
+ "The split holds a single class, so no ROC curve is defined."
+ )
+
+ figure.update_layout(
+ title="ROC curve (one vs rest)",
+ xaxis_title="False positive rate",
+ yaxis_title="True positive rate",
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="ROC curve")]
diff --git a/DashAI/back/reports/forecasting/__init__.py b/DashAI/back/reports/forecasting/__init__.py
new file mode 100644
index 000000000..9c0fa90a1
--- /dev/null
+++ b/DashAI/back/reports/forecasting/__init__.py
@@ -0,0 +1 @@
+# flake8: noqa
diff --git a/DashAI/back/reports/forecasting/forecast_vs_actual.py b/DashAI/back/reports/forecasting/forecast_vs_actual.py
new file mode 100644
index 000000000..ffcc2b7f8
--- /dev/null
+++ b/DashAI/back/reports/forecasting/forecast_vs_actual.py
@@ -0,0 +1,98 @@
+"""Forecast against actual values over the observation index."""
+
+from typing import List, Optional
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import BaseReport
+from DashAI.back.reports.regression.predicted_vs_actual import flat_predictions
+
+
+class ForecastVsActual(BaseReport):
+ """Forecast and truth as two lines over the observation index.
+
+ An error scalar says how far off the forecast is on average; this says
+ *where*. A forecast that tracks well early and drifts later shows a model
+ whose error compounds over the horizon, while a line that runs parallel
+ but offset reveals a level bias that RMSE folds into the rest of the
+ error. Row order is time order for a forecasting run, because the task
+ sorts its rows by date before anything downstream reads them.
+ """
+
+ COMPATIBLE_COMPONENTS = ["ForecastingTask"]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Forecast vs Actual",
+ es="Pronóstico vs Real",
+ pt="Previsão vs Real",
+ de="Prognose gegen Tatsächlich",
+ zh="预测值与实际值",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Forecast and truth as lines over time; reveals drift and bias.",
+ es="Pronóstico y verdad como líneas en el tiempo; revela deriva y sesgo.",
+ pt=("Previsão e verdade como linhas ao longo do tempo; revela deriva e viés."),
+ de=(
+ "Prognose und Wahrheit als Linien über die Zeit; zeigt Drift und "
+ "Verzerrung."
+ ),
+ zh="随时间变化的预测值与真实值曲线;可揭示漂移和偏差。",
+ )
+ COLOR: str = "#42A5F5"
+ ICON: str = "ShowChart"
+
+ def __init__(self, **kwargs) -> None:
+ """Initialise the report. It takes no parameters."""
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the forecast against actual line plot.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Ground truth values of the series.
+ y_pred : ndarray
+ The model's forecast for the same points.
+ class_names : Optional[List[str]]
+ Unused; always None for forecasting.
+
+ Returns
+ -------
+ List[Artifact]
+ A single figure holding the truth and the forecast lines.
+ """
+ import plotly.graph_objects as go
+
+ truth, predictions = flat_predictions(y_true, y_pred)
+ observations = list(range(len(truth)))
+
+ figure = go.Figure()
+ figure.add_trace(
+ go.Scatter(
+ x=observations,
+ y=truth.tolist(),
+ mode="lines",
+ name="Actual",
+ line={"width": 2, "color": "#42a5f5"},
+ )
+ )
+ figure.add_trace(
+ go.Scatter(
+ x=observations,
+ y=predictions.tolist(),
+ mode="lines",
+ name="Forecast",
+ line={"width": 2, "color": "#ffa726", "dash": "dash"},
+ )
+ )
+ figure.update_layout(
+ title="Forecast vs actual",
+ xaxis_title="Observation",
+ yaxis_title="Value",
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="Forecast vs actual")]
diff --git a/DashAI/back/reports/forecasting/residual_autocorrelation.py b/DashAI/back/reports/forecasting/residual_autocorrelation.py
new file mode 100644
index 000000000..1c6732422
--- /dev/null
+++ b/DashAI/back/reports/forecasting/residual_autocorrelation.py
@@ -0,0 +1,164 @@
+"""Residual autocorrelation report."""
+
+from typing import List, Optional
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.schema_fields import BaseSchema, int_field, schema_field
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import BaseReport, ReportError
+from DashAI.back.reports.regression.predicted_vs_actual import flat_predictions
+
+
+class ResidualAutocorrelationSchema(BaseSchema):
+ """Schema that configures the residual autocorrelation report."""
+
+ max_lag: schema_field(
+ int_field(ge=1, le=200),
+ placeholder=20,
+ description=MultilingualString(
+ en="Number of lags to plot the autocorrelation for.",
+ es="Número de rezagos para los cuales graficar la autocorrelación.",
+ pt="Número de defasagens para as quais plotar a autocorrelação.",
+ de="Anzahl der Lags, für die die Autokorrelation dargestellt wird.",
+ zh="要绘制自相关的滞后阶数。",
+ ),
+ alias=MultilingualString(
+ en="Max lag",
+ es="Rezago máximo",
+ pt="Defasagem máxima",
+ de="Maximale Verzögerung",
+ zh="最大滞后阶数",
+ ),
+ ) # type: ignore
+
+
+class ResidualAutocorrelation(BaseReport):
+ """Autocorrelation of the residuals against the number of lags.
+
+ A good forecast leaves residuals that look like noise: each error carries
+ no information about the next. Autocorrelation measures exactly that, so
+ a bar above the confidence band means the model left structure behind —
+ neighbouring periods are wrong in the same direction, which a tuned
+ model would have learned. Lags rising and falling smoothly usually mean
+ a missed trend or seasonality, while a single spike means a specific
+ lag that was never modelled.
+ """
+
+ SCHEMA = ResidualAutocorrelationSchema
+ COMPATIBLE_COMPONENTS = ["ForecastingTask"]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Residual Autocorrelation",
+ es="Autocorrelación de Residuos",
+ pt="Autocorrelação dos Resíduos",
+ de="Residuen-Autokorrelation",
+ zh="残差自相关",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Autocorrelation of the residuals by lag, with confidence band.",
+ es="Autocorrelación de los residuos por rezago, con banda de confianza.",
+ pt=("Autocorrelação dos resíduos por defasagem, com banda de confiança."),
+ de=("Autokorrelation der Residuen nach Lag, mit Konfidenzband."),
+ zh="按滞后阶数计算的残差自相关,附置信带。",
+ )
+ COLOR: str = "#26A69A"
+ ICON: str = "BarChart"
+
+ def __init__(self, max_lag: int = 20, **kwargs) -> None:
+ """Initialise the report.
+
+ Parameters
+ ----------
+ max_lag : int
+ Number of lags to plot the autocorrelation for.
+ **kwargs : dict
+ Ignored; accepted so unknown stored parameters do not break loading.
+ """
+ self.max_lag = max_lag
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the residual autocorrelation bar chart.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Ground truth values of the series.
+ y_pred : ndarray
+ The model's forecast for the same points.
+ class_names : Optional[List[str]]
+ Unused; always None for forecasting.
+
+ Returns
+ -------
+ List[Artifact]
+ A single bar figure with a confidence band around zero.
+
+ Raises
+ ------
+ ReportError
+ If the residuals are constant or there are not enough of them to
+ estimate a single lag.
+ """
+ import numpy as np
+ import plotly.graph_objects as go
+
+ truth, predictions = flat_predictions(y_true, y_pred)
+ residuals = truth - predictions
+
+ if len(residuals) < 2:
+ raise ReportError(
+ "The partition holds too few rows to estimate autocorrelation."
+ )
+
+ centered = residuals - residuals.mean()
+ denom = centered @ centered
+ if denom == 0:
+ raise ReportError(
+ "The residuals are constant, so autocorrelation is undefined."
+ )
+
+ lags = range(1, min(int(self.max_lag), len(residuals) - 1) + 1)
+ values = [(centered[k:] @ centered[:-k]) / denom for k in lags]
+
+ figure = go.Figure()
+ figure.add_trace(
+ go.Bar(
+ x=list(lags),
+ y=values,
+ name="Autocorrelation",
+ marker={"color": "#26a69a"},
+ )
+ )
+
+ band = 1.96 / np.sqrt(len(residuals))
+ figure.add_trace(
+ go.Scatter(
+ x=[lags[0], lags[-1]],
+ y=[band, band],
+ mode="lines",
+ name="Confidence band",
+ line={"dash": "dash", "width": 1, "color": "#9e9e9e"},
+ hoverinfo="skip",
+ )
+ )
+ figure.add_trace(
+ go.Scatter(
+ x=[lags[0], lags[-1]],
+ y=[-band, -band],
+ mode="lines",
+ showlegend=False,
+ line={"dash": "dash", "width": 1, "color": "#9e9e9e"},
+ hoverinfo="skip",
+ )
+ )
+ figure.update_layout(
+ title="Residual autocorrelation",
+ xaxis_title="Lag",
+ yaxis_title="Autocorrelation",
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="Residual autocorrelation")]
diff --git a/DashAI/back/reports/forecasting/residuals_over_time.py b/DashAI/back/reports/forecasting/residuals_over_time.py
new file mode 100644
index 000000000..1c071070d
--- /dev/null
+++ b/DashAI/back/reports/forecasting/residuals_over_time.py
@@ -0,0 +1,104 @@
+"""Residuals against the observation index report."""
+
+from typing import List, Optional
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import BaseReport
+from DashAI.back.reports.regression.predicted_vs_actual import flat_predictions
+
+
+class ResidualsOverTime(BaseReport):
+ """Residual against observation index, with the zero line.
+
+ The residual plot used by regression scatters residuals against the
+ predicted value; forecasting keeps that diagnostic but plots against
+ time, because time is where the error pattern lives. A band that widens
+ to the right means the forecast error compounds over the horizon, a
+ band that drifts off zero means the model is systematically behind or
+ ahead of the series, and a repeating pattern means a missed seasonality.
+ """
+
+ COMPATIBLE_COMPONENTS = ["ForecastingTask"]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Residuals Over Time",
+ es="Residuos en el Tiempo",
+ pt="Resíduos ao Longo do Tempo",
+ de="Residuen über die Zeit",
+ zh="残差随时间变化",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Residual against observation index; reveals drift and missed seasonality.",
+ es=(
+ "Residuo contra índice de observación; revela deriva y estacionalidad "
+ "omitida."
+ ),
+ pt=(
+ "Resíduo contra índice de observação; revela deriva e sazonalidade perdida."
+ ),
+ de=(
+ "Residuum gegen Beobachtungsindex; zeigt Drift und übersehene Saisonalität."
+ ),
+ zh="残差与观测序号的对应关系;可揭示漂移与遗漏的季节性。",
+ )
+ COLOR: str = "#EF5350"
+ ICON: str = "Timeline"
+
+ def __init__(self, **kwargs) -> None:
+ """Initialise the report. It takes no parameters."""
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the residuals against observation index scatter.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Ground truth values of the series.
+ y_pred : ndarray
+ The model's forecast for the same points.
+ class_names : Optional[List[str]]
+ Unused; always None for forecasting.
+
+ Returns
+ -------
+ List[Artifact]
+ A single scatter figure with the zero reference line.
+ """
+ import plotly.graph_objects as go
+
+ truth, predictions = flat_predictions(y_true, y_pred)
+ residuals = truth - predictions
+ observations = list(range(len(truth)))
+
+ figure = go.Figure()
+ figure.add_trace(
+ go.Scatter(
+ x=[observations[0], observations[-1]],
+ y=[0, 0],
+ mode="lines",
+ name="Zero error",
+ line={"dash": "dash", "width": 1, "color": "#9e9e9e"},
+ hoverinfo="skip",
+ )
+ )
+ figure.add_trace(
+ go.Scatter(
+ x=observations,
+ y=residuals.tolist(),
+ mode="markers",
+ name="Residuals",
+ marker={"size": 6, "opacity": 0.7, "color": "#ef5350"},
+ )
+ )
+ figure.update_layout(
+ title="Residuals over time",
+ xaxis_title="Observation",
+ yaxis_title="Residual (actual minus forecast)",
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="Residuals over time")]
diff --git a/DashAI/back/reports/regression/__init__.py b/DashAI/back/reports/regression/__init__.py
new file mode 100644
index 000000000..9c0fa90a1
--- /dev/null
+++ b/DashAI/back/reports/regression/__init__.py
@@ -0,0 +1 @@
+# flake8: noqa
diff --git a/DashAI/back/reports/regression/predicted_vs_actual.py b/DashAI/back/reports/regression/predicted_vs_actual.py
new file mode 100644
index 000000000..3eb608257
--- /dev/null
+++ b/DashAI/back/reports/regression/predicted_vs_actual.py
@@ -0,0 +1,117 @@
+"""Predicted against actual report."""
+
+from typing import List, Optional
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import BaseReport
+
+
+def flat_predictions(y_true, y_pred):
+ """Flatten the truth and predictions into aligned 1D float arrays.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Ground truth values.
+ y_pred : ndarray
+ Model predictions.
+
+ Returns
+ -------
+ Tuple[np.ndarray, np.ndarray]
+ The truth and the predictions, both 1D and float typed.
+ """
+ import numpy as np
+
+ return (
+ np.asarray(y_true, dtype=float).ravel(),
+ np.asarray(y_pred, dtype=float).ravel(),
+ )
+
+
+class PredictedVsActual(BaseReport):
+ """Scatter of predicted against true values with the identity line.
+
+ R squared and RMSE say how far off the model is on average; this says
+ *where*. Points bending away from the identity line at one end reveal a
+ model that is accurate in the middle of the range and biased at the
+ extremes, which no single error scalar can express.
+ """
+
+ COMPATIBLE_COMPONENTS = ["RegressionTask"]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Predicted vs Actual",
+ es="Predicho vs Real",
+ pt="Previsto vs Real",
+ de="Vorhergesagt gegen Tatsächlich",
+ zh="预测值与实际值",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Predictions against the truth, with the perfect prediction line.",
+ es="Predicciones contra la verdad, con la línea de predicción perfecta.",
+ pt="Previsões contra a verdade, com a linha de previsão perfeita.",
+ de="Vorhersagen gegen die Wahrheit, mit der perfekten Vorhersagelinie.",
+ zh="预测值与真实值的对比,附完美预测参考线。",
+ )
+ COLOR: str = "#42A5F5"
+ ICON: str = "ScatterPlot"
+
+ def __init__(self, **kwargs) -> None:
+ """Initialise the report. It takes no parameters."""
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the predicted against actual scatter.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Ground truth values.
+ y_pred : ndarray
+ Model predictions.
+ class_names : Optional[List[str]]
+ Unused; always None for regression.
+
+ Returns
+ -------
+ List[Artifact]
+ A single scatter figure with the identity reference line.
+ """
+ import plotly.graph_objects as go
+
+ truth, predictions = flat_predictions(y_true, y_pred)
+ low = float(min(truth.min(), predictions.min()))
+ high = float(max(truth.max(), predictions.max()))
+
+ figure = go.Figure()
+ figure.add_trace(
+ go.Scatter(
+ x=[low, high],
+ y=[low, high],
+ mode="lines",
+ name="Perfect prediction",
+ line={"dash": "dash", "width": 1, "color": "#9e9e9e"},
+ hoverinfo="skip",
+ )
+ )
+ figure.add_trace(
+ go.Scatter(
+ x=truth.tolist(),
+ y=predictions.tolist(),
+ mode="markers",
+ name="Predictions",
+ marker={"size": 6, "opacity": 0.7, "color": "#42a5f5"},
+ )
+ )
+ figure.update_layout(
+ title="Predicted vs actual",
+ xaxis_title="Actual",
+ yaxis_title="Predicted",
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="Predicted vs actual")]
diff --git a/DashAI/back/reports/regression/residual_histogram.py b/DashAI/back/reports/regression/residual_histogram.py
new file mode 100644
index 000000000..5c21eb051
--- /dev/null
+++ b/DashAI/back/reports/regression/residual_histogram.py
@@ -0,0 +1,118 @@
+"""Residual distribution report."""
+
+from typing import List, Optional
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.schema_fields import BaseSchema, int_field, schema_field
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import BaseReport
+from DashAI.back.reports.regression.predicted_vs_actual import flat_predictions
+
+
+class ResidualHistogramSchema(BaseSchema):
+ """Schema that configures the residual histogram."""
+
+ bins: schema_field(
+ int_field(ge=5, le=200),
+ placeholder=30,
+ description=MultilingualString(
+ en="Number of bins used to bucket the residuals.",
+ es="Número de contenedores usados para agrupar los residuos.",
+ pt="Número de compartimentos usados para agrupar os resíduos.",
+ de="Anzahl der Klassen zur Gruppierung der Residuen.",
+ zh="用于划分残差的分箱数量。",
+ ),
+ alias=MultilingualString(
+ en="Bins", es="Contenedores", pt="Compartimentos", de="Klassen", zh="分箱数"
+ ),
+ ) # type: ignore
+
+
+class ResidualHistogram(BaseReport):
+ """Distribution of the residuals, with the zero line marked.
+
+ Shows the shape of the error rather than its magnitude. Residuals should
+ centre on zero and fall away symmetrically; a shifted centre means constant
+ bias, a long tail on one side means the model fails asymmetrically, and two
+ peaks usually mean a subpopulation the model treats as one group.
+ """
+
+ SCHEMA = ResidualHistogramSchema
+ COMPATIBLE_COMPONENTS = ["RegressionTask"]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Residual Histogram",
+ es="Histograma de Residuos",
+ pt="Histograma de Resíduos",
+ de="Residuen-Histogramm",
+ zh="残差直方图",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Distribution of the errors; should centre on zero and look symmetric.",
+ es=(
+ "Distribución de los errores; debería centrarse en cero y verse simétrica."
+ ),
+ pt=("Distribuição dos erros; deve centrar-se em zero e parecer simétrica."),
+ de=("Verteilung der Fehler; sollte bei null zentriert und symmetrisch sein."),
+ zh="误差的分布;应以零为中心且大致对称。",
+ )
+ COLOR: str = "#FFA726"
+ ICON: str = "BarChart"
+
+ def __init__(self, bins: int = 30, **kwargs) -> None:
+ """Initialise the report.
+
+ Parameters
+ ----------
+ bins : int
+ Number of histogram bins.
+ **kwargs : dict
+ Ignored; accepted so unknown stored parameters do not break loading.
+ """
+ self.bins = bins
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the residual histogram.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Ground truth values.
+ y_pred : ndarray
+ Model predictions.
+ class_names : Optional[List[str]]
+ Unused; always None for regression.
+
+ Returns
+ -------
+ List[Artifact]
+ A single histogram figure with a zero reference line.
+ """
+ import plotly.graph_objects as go
+
+ truth, predictions = flat_predictions(y_true, y_pred)
+ residuals = truth - predictions
+
+ figure = go.Figure(
+ go.Histogram(
+ x=residuals.tolist(),
+ nbinsx=int(self.bins),
+ marker={"color": "#ffa726"},
+ name="Residuals",
+ )
+ )
+ figure.add_vline(x=0, line_dash="dash", line_color="#9e9e9e")
+ figure.update_layout(
+ title=(
+ f"Residual distribution (mean {residuals.mean():.4g}, "
+ f"std {residuals.std():.4g})"
+ ),
+ xaxis_title="Residual (actual minus predicted)",
+ yaxis_title="Count",
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="Residual histogram")]
diff --git a/DashAI/back/reports/regression/residual_plot.py b/DashAI/back/reports/regression/residual_plot.py
new file mode 100644
index 000000000..7730071b1
--- /dev/null
+++ b/DashAI/back/reports/regression/residual_plot.py
@@ -0,0 +1,98 @@
+"""Residual against predicted report."""
+
+from typing import List, Optional
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import BaseReport
+from DashAI.back.reports.regression.predicted_vs_actual import flat_predictions
+
+
+class ResidualPlot(BaseReport):
+ """Residual against predicted value, with the zero line.
+
+ A well specified model leaves residuals scattered as a formless band around
+ zero. Structure in this plot is a diagnosis: curvature means a missing
+ nonlinear term, a widening fan means heteroscedasticity, and a residual
+ band that drifts off zero means systematic bias over part of the range.
+ An error scalar reports the size of these problems but not their shape.
+ """
+
+ COMPATIBLE_COMPONENTS = ["RegressionTask"]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Residual Plot",
+ es="Gráfico de Residuos",
+ pt="Gráfico de Resíduos",
+ de="Residuendiagramm",
+ zh="残差图",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Residual against predicted value; reveals bias and heteroscedasticity.",
+ es=("Residuo contra valor predicho; revela sesgo y heterocedasticidad."),
+ pt=("Resíduo contra valor previsto; revela viés e heterocedasticidade."),
+ de=(
+ "Residuum gegen vorhergesagten Wert; zeigt Verzerrung und "
+ "Heteroskedastizität."
+ ),
+ zh="残差与预测值的关系;可揭示偏差与异方差性。",
+ )
+ COLOR: str = "#EF5350"
+ ICON: str = "BubbleChart"
+
+ def __init__(self, **kwargs) -> None:
+ """Initialise the report. It takes no parameters."""
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the residual against predicted scatter.
+
+ Parameters
+ ----------
+ y_true : ndarray
+ Ground truth values.
+ y_pred : ndarray
+ Model predictions.
+ class_names : Optional[List[str]]
+ Unused; always None for regression.
+
+ Returns
+ -------
+ List[Artifact]
+ A single scatter figure with the zero reference line.
+ """
+ import plotly.graph_objects as go
+
+ truth, predictions = flat_predictions(y_true, y_pred)
+ residuals = truth - predictions
+
+ figure = go.Figure()
+ figure.add_trace(
+ go.Scatter(
+ x=[float(predictions.min()), float(predictions.max())],
+ y=[0, 0],
+ mode="lines",
+ name="Zero error",
+ line={"dash": "dash", "width": 1, "color": "#9e9e9e"},
+ hoverinfo="skip",
+ )
+ )
+ figure.add_trace(
+ go.Scatter(
+ x=predictions.tolist(),
+ y=residuals.tolist(),
+ mode="markers",
+ name="Residuals",
+ marker={"size": 6, "opacity": 0.7, "color": "#ef5350"},
+ )
+ )
+ figure.update_layout(
+ title="Residuals vs predicted",
+ xaxis_title="Predicted",
+ yaxis_title="Residual (actual minus predicted)",
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="Residual plot")]
diff --git a/DashAI/back/reports/translation/__init__.py b/DashAI/back/reports/translation/__init__.py
new file mode 100644
index 000000000..9c0fa90a1
--- /dev/null
+++ b/DashAI/back/reports/translation/__init__.py
@@ -0,0 +1 @@
+# flake8: noqa
diff --git a/DashAI/back/reports/translation/length_comparison.py b/DashAI/back/reports/translation/length_comparison.py
new file mode 100644
index 000000000..1425a1cd0
--- /dev/null
+++ b/DashAI/back/reports/translation/length_comparison.py
@@ -0,0 +1,99 @@
+"""Reference against translation length comparison report."""
+
+from typing import List, Optional
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import BaseReport
+from DashAI.back.reports.translation.per_segment_comparison import as_text_pairs
+
+
+class LengthComparison(BaseReport):
+ """Reference length against translation length, with the identity line.
+
+ Translation metrics score content, not length, so a model can earn good
+ BLEU while systematically truncating or padding its output. This plot
+ shows that shape directly: points hugging the identity line translate
+ with faithful lengths, a cloud consistently below the line means the
+ model shortens its output, and a horizontal band at one length means it
+ produces roughly the same size of translation regardless of input.
+ """
+
+ COMPATIBLE_COMPONENTS = ["TranslationTask"]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Length Comparison",
+ es="Comparación de Longitudes",
+ pt="Comparação de Comprimentos",
+ de="Längenvergleich",
+ zh="长度对比",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Reference length against translation length; reveals truncation.",
+ es="Longitud de referencia contra traducción; revela truncamiento.",
+ pt=("Comprimento de referência contra tradução; revela truncamento."),
+ de=("Referenzlänge gegen Übersetzungslänge; zeigt Kürzung."),
+ zh="参考文本长度与译文长度的对比;可揭示截断问题。",
+ )
+ COLOR: str = "#FFA726"
+ ICON: str = "ScatterPlot"
+
+ def __init__(self, **kwargs) -> None:
+ """Initialise the report. It takes no parameters."""
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the reference against translation length scatter.
+
+ Parameters
+ ----------
+ y_true : array_like
+ Reference translations, one per row.
+ y_pred : array_like
+ The model's translations for the same rows.
+ class_names : Optional[List[str]]
+ Unused; always None for translation.
+
+ Returns
+ -------
+ List[Artifact]
+ A single scatter figure with the identity reference line.
+ """
+ import plotly.graph_objects as go
+
+ truth, hypothesis = as_text_pairs(y_true, y_pred)
+ reference_lengths = [len(text) for text in truth]
+ hypothesis_lengths = [len(text) for text in hypothesis]
+ low = min(min(reference_lengths), min(hypothesis_lengths))
+ high = max(max(reference_lengths), max(hypothesis_lengths))
+
+ figure = go.Figure()
+ figure.add_trace(
+ go.Scatter(
+ x=[low, high],
+ y=[low, high],
+ mode="lines",
+ name="Perfect match",
+ line={"dash": "dash", "width": 1, "color": "#9e9e9e"},
+ hoverinfo="skip",
+ )
+ )
+ figure.add_trace(
+ go.Scatter(
+ x=reference_lengths,
+ y=hypothesis_lengths,
+ mode="markers",
+ name="Segments",
+ marker={"size": 7, "opacity": 0.7, "color": "#ffa726"},
+ )
+ )
+ figure.update_layout(
+ title="Reference vs translation length",
+ xaxis_title="Reference length (characters)",
+ yaxis_title="Translation length (characters)",
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="Length comparison")]
diff --git a/DashAI/back/reports/translation/per_segment_comparison.py b/DashAI/back/reports/translation/per_segment_comparison.py
new file mode 100644
index 000000000..c74778d32
--- /dev/null
+++ b/DashAI/back/reports/translation/per_segment_comparison.py
@@ -0,0 +1,197 @@
+"""Per segment reference against translation comparison table."""
+
+from typing import List, Optional
+
+import numpy as np
+
+from DashAI.back.core.artifacts import (
+ Artifact,
+ TableArtifact,
+ TableCell,
+ TablePayload,
+)
+from DashAI.back.core.schema_fields import BaseSchema, int_field, schema_field
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import BaseReport, ReportError
+
+
+def as_text_pairs(y_true, y_pred):
+ """Flatten the truth and predictions into aligned lists of strings.
+
+ Parameters
+ ----------
+ y_true : array_like
+ Reference translations, one per row.
+ y_pred : array_like
+ The model's translations for the same rows.
+
+ Returns
+ -------
+ tuple of list of str
+ The references and the hypotheses, both as flat string lists.
+
+ Raises
+ ------
+ ReportError
+ If the two inputs hold a different number of segments.
+ """
+ truth = [str(value) for value in np.asarray(y_true).ravel()]
+ hypothesis = [str(value) for value in np.asarray(y_pred).ravel()]
+ if len(truth) != len(hypothesis):
+ raise ReportError(
+ "The number of reference and translated segments must match, "
+ f"given {len(truth)} and {len(hypothesis)}."
+ )
+ return truth, hypothesis
+
+
+def per_segment_scores(y_true, y_pred) -> List[float]:
+ """Score every segment with sentence level BLEU.
+
+ Sentence BLEU on short strings is degenerate without smoothing, so the
+ score uses exponential smoothing and the effective order, which makes an
+ exact match a 100 and a complete miss a 0. It is a diagnostic, not the
+ corpus level BLEU the metric reports, so the two are free to disagree on
+ any one split.
+
+ Parameters
+ ----------
+ y_true : array_like
+ Reference translations, one per row.
+ y_pred : array_like
+ The model's translations for the same rows.
+
+ Returns
+ -------
+ list of float
+ One score in the 0-100 range per segment.
+ """
+ from sacrebleu.metrics import BLEU
+
+ truth, hypothesis = as_text_pairs(y_true, y_pred)
+ bleu = BLEU(smooth_method="exp", effective_order=True)
+ scores = [
+ float(bleu.sentence_score(hypothesis[index], [truth[index]]).score)
+ for index in range(len(truth))
+ ]
+ return [min(100.0, max(0.0, score)) for score in scores]
+
+
+class PerSegmentComparisonSchema(BaseSchema):
+ """Schema that configures the per segment comparison report."""
+
+ highlight_count: schema_field(
+ int_field(ge=0, le=100),
+ placeholder=5,
+ description=MultilingualString(
+ en="How many of the lowest scoring segments to highlight.",
+ es="Cuántos de los segmentos con menor puntuación resaltar.",
+ pt="Quantos dos segmentos com menor pontuação destacar.",
+ de="Wie viele der am schlechtesten bewerteten Segmente hervorheben.",
+ zh="要突出显示的最低评分片段数量。",
+ ),
+ alias=MultilingualString(
+ en="Highlight count",
+ es="Cantidad a resaltar",
+ pt="Quantidade a destacar",
+ de="Hervorzuhebende Anzahl",
+ zh="突出显示数量",
+ ),
+ ) # type: ignore
+
+
+class PerSegmentComparison(BaseReport):
+ """Reference against translation for every segment, worst ones highlighted.
+
+ The corpus level BLEU, CHRF and TER metrics condense a whole split into
+ one number. This table is the split itself: it pairs each reference with
+ its translation and a sentence level score, and highlights the lowest
+ scoring rows so the segments dragging the aggregate down are the first
+ thing seen. A concentration of low scores in long or short segments, or
+ around a particular topic, is exactly what the average cannot say.
+ """
+
+ SCHEMA = PerSegmentComparisonSchema
+ COMPATIBLE_COMPONENTS = ["TranslationTask"]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Per Segment Comparison",
+ es="Comparación por Segmento",
+ pt="Comparação por Segmento",
+ de="Vergleich je Segment",
+ zh="按片段比较",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Reference against translation per segment, with the worst highlighted.",
+ es=("Referencia contra traducción por segmento, con los peores resaltados."),
+ pt=("Referência contra tradução por segmento, com os piores destacados."),
+ de=(
+ "Referenz gegen Übersetzung je Segment, mit hervorgehobenen Schlechtesten."
+ ),
+ zh="逐个片段对比参考译文与翻译结果,并突出显示最差片段。",
+ )
+ COLOR: str = "#66BB6A"
+ ICON: str = "TableChart"
+
+ def __init__(self, highlight_count: int = 5, **kwargs) -> None:
+ """Initialise the report.
+
+ Parameters
+ ----------
+ highlight_count : int
+ How many of the lowest scoring segments to highlight.
+ **kwargs : dict
+ Ignored; accepted so unknown stored parameters do not break loading.
+ """
+ self.highlight_count = highlight_count
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the per segment comparison table.
+
+ Parameters
+ ----------
+ y_true : array_like
+ Reference translations, one per row.
+ y_pred : array_like
+ The model's translations for the same rows.
+ class_names : Optional[List[str]]
+ Unused; always None for translation.
+
+ Returns
+ -------
+ List[Artifact]
+ A single table, one row per segment plus the average row.
+ """
+ truth, hypothesis = as_text_pairs(y_true, y_pred)
+ scores = per_segment_scores(y_true, y_pred)
+
+ rows = [
+ [
+ index + 1,
+ truth[index],
+ hypothesis[index],
+ round(scores[index], 2),
+ ]
+ for index in range(len(truth))
+ ]
+ average = sum(scores) / len(scores) if scores else 0.0
+ rows.append(["average", "", "", round(float(average), 2)])
+
+ count = min(int(self.highlight_count), len(scores))
+ worst = sorted(range(len(scores)), key=lambda i: scores[i])[:count]
+ highlight = [TableCell(row=index, column=3) for index in worst]
+
+ return [
+ TableArtifact(
+ payload=TablePayload(
+ columns=["#", "Reference", "Translation", "Score"],
+ rows=rows,
+ highlight=highlight,
+ ),
+ title="Per segment comparison",
+ )
+ ]
diff --git a/DashAI/back/reports/translation/segment_score_distribution.py b/DashAI/back/reports/translation/segment_score_distribution.py
new file mode 100644
index 000000000..0a33c9c44
--- /dev/null
+++ b/DashAI/back/reports/translation/segment_score_distribution.py
@@ -0,0 +1,127 @@
+"""Distribution of per segment translation scores report."""
+
+from typing import List, Optional
+
+from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.schema_fields import BaseSchema, int_field, schema_field
+from DashAI.back.core.utils import MultilingualString
+from DashAI.back.reports.base_report import BaseReport
+from DashAI.back.reports.translation.per_segment_comparison import (
+ per_segment_scores,
+)
+
+
+class SegmentScoreDistributionSchema(BaseSchema):
+ """Schema that configures the segment score distribution report."""
+
+ bins: schema_field(
+ int_field(ge=5, le=200),
+ placeholder=20,
+ description=MultilingualString(
+ en="Number of bins used to bucket the per segment scores.",
+ es=(
+ "Número de contenedores usados para agrupar las puntuaciones "
+ "por segmento."
+ ),
+ pt=(
+ "Número de compartimentos usados para agrupar as pontuações "
+ "por segmento."
+ ),
+ de="Anzahl der Klassen zur Gruppierung der Segment-Scores.",
+ zh="用于划分每片段分数的分箱数量。",
+ ),
+ alias=MultilingualString(
+ en="Bins", es="Contenedores", pt="Compartimentos", de="Klassen", zh="分箱数"
+ ),
+ ) # type: ignore
+
+
+class SegmentScoreDistribution(BaseReport):
+ """Distribution of the sentence level BLEU scores across segments.
+
+ The corpus BLEU metric is a single point on this histogram; the histogram
+ is the shape of the quality. A split whose scores cluster high with a
+ handful near zero contains a few broken translations among otherwise good
+ ones, while a wide flat distribution means quality is uniformly mediocre —
+ a different problem to solve, and one the aggregate cannot tell apart.
+ """
+
+ SCHEMA = SegmentScoreDistributionSchema
+ COMPATIBLE_COMPONENTS = ["TranslationTask"]
+ DISPLAY_NAME: str = MultilingualString(
+ en="Segment Score Distribution",
+ es="Distribución de Puntuaciones por Segmento",
+ pt="Distribuição de Pontuações por Segmento",
+ de="Verteilung der Segment-Scores",
+ zh="片段分数分布",
+ )
+ DESCRIPTION: str = MultilingualString(
+ en="Histogram of the per segment BLEU scores, with the mean marked.",
+ es=("Histograma de las puntuaciones BLEU por segmento, con la media marcada."),
+ pt=("Histograma das pontuações BLEU por segmento, com a média marcada."),
+ de=("Histogramm der BLEU-Scores je Segment, mit markiertem Mittelwert."),
+ zh="每个片段的 BLEU 分数直方图,并标注平均值。",
+ )
+ COLOR: str = "#AB47BC"
+ ICON: str = "BarChart"
+
+ def __init__(self, bins: int = 20, **kwargs) -> None:
+ """Initialise the report.
+
+ Parameters
+ ----------
+ bins : int
+ Number of histogram bins.
+ **kwargs : dict
+ Ignored; accepted so unknown stored parameters do not break loading.
+ """
+ self.bins = bins
+
+ def compute(
+ self,
+ y_true,
+ y_pred,
+ class_names: Optional[List[str]] = None,
+ ) -> List[Artifact]:
+ """Build the segment score distribution histogram.
+
+ Parameters
+ ----------
+ y_true : array_like
+ Reference translations, one per row.
+ y_pred : array_like
+ The model's translations for the same rows.
+ class_names : Optional[List[str]]
+ Unused; always None for translation.
+
+ Returns
+ -------
+ List[Artifact]
+ A single histogram figure with a mean reference line.
+ """
+ import plotly.graph_objects as go
+
+ scores = per_segment_scores(y_true, y_pred)
+
+ figure = go.Figure(
+ go.Histogram(
+ x=scores,
+ nbinsx=int(self.bins),
+ marker={"color": "#ab47bc"},
+ name="Scores",
+ )
+ )
+ figure.add_vline(
+ x=sum(scores) / len(scores),
+ line_dash="dash",
+ line_color="#9e9e9e",
+ )
+ figure.update_layout(
+ title=(
+ f"Segment score distribution (mean {sum(scores) / len(scores):.4g})"
+ ),
+ xaxis_title="Sentence BLEU",
+ yaxis_title="Count",
+ margin={"l": 20, "r": 20, "t": 50, "b": 40},
+ )
+ return [PlotlyArtifact(payload=figure, title="Segment score distribution")]
diff --git a/DashAI/front/src/api/explainer.ts b/DashAI/front/src/api/explainer.ts
index 623f78e26..3ba9445cc 100644
--- a/DashAI/front/src/api/explainer.ts
+++ b/DashAI/front/src/api/explainer.ts
@@ -121,7 +121,7 @@ export const saveExplainerPlotOverride = async (
return response.data;
};
-export const resetExplainerPlotOverride = async (
+export const deleteExplainerPlotOverride = async (
scope: string,
explainerId: number,
index: number,
diff --git a/DashAI/front/src/api/job.ts b/DashAI/front/src/api/job.ts
index b3cd69da4..9907722c4 100644
--- a/DashAI/front/src/api/job.ts
+++ b/DashAI/front/src/api/job.ts
@@ -51,6 +51,18 @@ export const enqueueRunnerJob = async (runId: number): Promise