Skip to main content

Evaluate an image-classification model

This guide evaluates a hosted image classifier against a small, balanced EuroSAT sample. It follows the same governed lifecycle as object detection, but uses the classification task contract throughout: a gdi_image_v1 project with task_type="classification", one non-empty class-name string in each row's label, the huggingface_image_classification SUT protocol, and the image_classification.* metric family.

The complete executable version is aip_v2_demo_classification_e2e_satellite.ipynb. It reads credentials only from environment variables. Start Jupyter from libs/aip-sdk after exporting the values shown in examples/.env.example. The first cell fails immediately unless AIP_BASE_URL, SATELLITE_ENDPOINT_URL, HF_TOKEN, and one supported AIP authentication method are present; it never writes placeholder credentials into the process environment.

Connect and create the classification project​

Initialize the SDK with one supported authentication method, then create the task-scoped image project. A dimension is optional; scenario is useful here for comparing the source train/test slices later.

import os
import aip_sdk as aip

aip.init(
os.environ["AIP_BASE_URL"],
token=os.environ.get("AIP_TOKEN"),
api_key=os.environ.get("AIP_API_KEY"),
username=os.environ.get("AIP_USERNAME"),
password=os.environ.get("AIP_PASSWORD"),
)
workspace_name = os.environ.get("AIP_WORKSPACE_NAME")
workspace = aip.Workspace.get_by_name(workspace_name) if workspace_name else aip.Workspace.default()

project, _ = aip.Project.get_or_create(
name="EuroSAT Image Classification",
schema="gdi_image_v1",
task_type="classification",
dimensions=[
aip.Dimension(
name="scenario",
column="scenario",
values=["test", "train"],
required=False,
)
],
workspace_id=workspace.id,
)

Build and validate the classification rows​

Each row needs image_id, task_type="classification", a non-empty string label, and either embedded image bytes or an image_path. The hosted SUT populates predictions; each prediction is a {"label": <class>, "score": <probability>} entry. Keep a small example class-balanced so every class is represented in the confusion matrix. Five rows from each EuroSAT class gives a 50-row run that remains quick but exercises all ten classes.

import pandas as pd
from aip_core.schemas.image import GdiImageV1

df_full = pd.read_parquet("examples/data/eurosat_gdi_image_v1.parquet")
df = (
df_full.groupby("label", group_keys=False)
.sample(n=5, random_state=17)
.sort_values(["label", "image_id"])
.reset_index(drop=True)
)
GdiImageV1.validate(df)

assert set(df["task_type"]) == {"classification"}
assert df["label"].map(lambda value: isinstance(value, str) and bool(value.strip())).all()
assert df["label"].nunique() >= 2 # required by ROC/AUC

Classification labels are deliberately different from detection annotations. Nulls, empty strings, lists, and dictionaries are malformed. For example, this detection-shaped value fails the dataframe-level check_label_matches_task_type validation and raises Pandera SchemaError:

bad = df.head(1).copy()
bad["label"] = pd.Series([[{"class": "Forest"}]], index=bad.index)
GdiImageV1.validate(bad) # SchemaError: classification label must be a non-empty string

Fix the row by using the class name itself:

bad["label"] = "Forest"
GdiImageV1.validate(bad)

Register the classifier and response adapter​

Register the SUT under the project and add a connection using huggingface_image_classification. top_k must cover the class vocabulary so metrics receive the full score distribution needed by ROC/AUC, rather than only the winning label. The built-in cv_classification adapter maps the normalized endpoint response into the GDI predictions output.

sut, _ = aip.Sut.get_or_register(
name="hf-eurosat-classifier",
version="1.0",
project_id=project.id,
)
conn = sut.add_connection(
label="hf-eurosat",
base_url=os.environ["SATELLITE_ENDPOINT_URL"],
auth_type="bearer",
auth_header_name="Authorization",
auth_header_value=os.environ["HF_TOKEN"],
model_params={
"top_k": int(df["label"].nunique()),
"parameters": {"return_all_scores": True},
},
sut_protocol="huggingface_image_classification",
gdi_schema="gdi_image_v1",
task_type="classification",
)
conn.set_adapter(
template_name="cv_classification",
mapping_config={"predictions": "$.predictions"},
)

Upload, check, and promote the dataset​

Upload the validated frame, apply the identity mapping, persist a local quality report, and promote the mapped version to golden. Promotion without a passing verdict is rejected; do not force it in the supported happy path.

import time

dataset = project.upload_dataset(
df,
name=f"eurosat-classification-{int(time.time())}",
)
version = dataset.latest_version()
version = dataset.map_version(
version.id,
column_mapping={
"image_id": "image_id",
"task_type": "task_type",
"label": "label",
"image": "image",
"scenario": "scenario",
"metadata": "metadata",
},
)

report = dataset.run_checks(
version.id,
label="classification example",
checks=["row_count", "duplicate_ids", "schema_conformance", "dimension_coverage"],
)
version = dataset.promote(version.id)

Select classification metrics​

The classification family has five scalar metrics and four report artifacts. Confirm the names against the live registry before launching the run:

metrics = [
"image_classification.accuracy",
"image_classification.recall",
"image_classification.specificity",
"image_classification.fnr",
"image_classification.per_class_recall",
"image_classification.per_class_specificity",
"image_classification.per_class_fnr",
"image_classification.confusion_matrix",
"image_classification.roc_auc",
]

available = {entry["name"].split("/", 1)[0] for entry in aip.ops.list_metrics(schema="gdi_image_v1")}
missing = set(metrics) - available
assert not missing, f"Missing live classification metrics: {sorted(missing)}"

Run and inspect the current report​

Supplying the registered connection selects the hosted runner. poll_status raises on failure, cancellation, or timeout, so a failed example cannot look successful. Scalar summaries and report artifacts are available through the public results/analysis surfaces after completion.

with aip.run(
project=project.id,
dataset=f"{dataset.id}@v{version.version}",
sut_id=sut.id,
connection_id=conn.id,
metrics=metrics,
) as run:
run.poll_status(interval=5, timeout=900)
page = run.results(page=1, page_size=1000)
analysis = run.analysis()
print(run.url)

analysis["scorer_stats"] contains the five scalar summaries used by score cards. FNR is lower-is-better; its default classification score card passes at <= 0.2 and warns at <= 0.4. The remaining outputs are under analysis["metric_artifacts"]:

artifacts = analysis["metric_artifacts"]
matrix = artifacts["confusion_matrix"]

assert matrix["type"] == "matrix"
assert len(matrix["matrix"]) == len(matrix["labels"])
assert sum(sum(row) for row in matrix["matrix"]) == len(df)

for name in ("per_class_recall", "per_class_specificity", "per_class_fnr"):
assert artifacts[name]["type"] == "per_class_scores"

Open run.url to verify the current report widgets: scalar score cards, classification confusion matrix, and per-class recall/specificity/FNR. These views come from the current widget system and do not require the interactive report engine.