Sentiment flywheel (IMDB → synthesize → HITL → train → online)
This example project builds a binary sentiment dataset for movie reviews and wires it through the full py-gen-ml loop:
Offline (train-time)
- Seed with IMDB-style reviews (bundled JSON)
- Synthesize more labeled rows with PydanticAI (
NativeOutput) against OpenAI - HITL — push rows to Argilla (
FIELDtext /QUESTIONsentiment) for review - Persist accepted rows to LanceDB (optional)
- Train a small TF–IDF + logistic regression classifier
- Track the run with MLflow
Online (serve-time)
- Serve scored reviews via LitServe (
SentimentPredictRequest→SentimentPrediction) - Store predictions in LanceDB (separate table from labeled examples)
- Re-label via Argilla on
SentimentFeedback(predicted label as suggestion) - Store feedback rows and track agreement with MLflow
One protobuf file defines the contracts: labeled SentimentExample,
serve I/O, separate SentimentPrediction / SentimentFeedback, plus train and
online metrics. Enable several generators and you get adapters together—no
duplicated schemas per tool.
flowchart LR
proto["sentiment_demo.proto"]
proto --> row["SentimentExample FEATURE_ROW"]
proto --> pred["SentimentPrediction PREDICTION"]
proto --> fb["SentimentFeedback FEEDBACK"]
proto --> cfg["SentimentTrainConfig RUN_CONFIG"]
proto --> met["SentimentMetrics METRIC_SET"]
proto --> onlineMet["SentimentOnlineMetrics METRIC_SET"]
proto --> serve["SentimentClassifier LitServe"]
row --> pai["*_pydantic_ai.py"]
row --> arg["*_argilla.py"]
row --> lance["*_lancedb.py"]
pred --> lance
fb --> arg
fb --> lance
cfg --> mlflow["*_mlflow.py"]
cfg --> cli["*_cli_args.py"]
met --> mlflow
onlineMet --> mlflow
serve --> litserve["*_litserve.py"]
pai --> run["sentiment_flywheel_demo.py"]
arg --> run
lance --> run
mlflow --> run
cli --> run
litserve --> online["sentiment_online_demo.py"]
lance --> online
arg --> online
mlflow --> online
flowchart TB
imdb["IMDB seed JSON"] --> synth["synthesize_sentiment_example"]
synth --> argilla["Argilla FIELD text / QUESTION sentiment"]
argilla --> labels["Accepted labels"]
labels --> lanceStore["LanceDB examples"]
labels --> train["TF-IDF + LogReg"]
train --> track["MLflow"]
flowchart TB
req["SentimentPredictRequest"] --> lit["LitServe Predict"]
lit --> prediction["SentimentPrediction"]
prediction --> lancePred["LanceDB predictions"]
prediction --> fbDraft["SentimentFeedback draft"]
fbDraft --> argFb["Argilla re-label"]
argFb --> feedback["SentimentFeedback human"]
feedback --> lanceFb["LanceDB feedback"]
prediction --> onlineTrack["MLflow agreement"]
feedback --> onlineTrack
One schema file, many generators
syntax = "proto3";
package demo;
import "py_gen_ml/extensions.proto";
// Labeled movie-review example for sentiment classification.
// Field comments become JSON Schema descriptions for PydanticAI NativeOutput.
message SentimentExample {
option (pgml.kind) = FEATURE_ROW;
option (pgml.pydantic_ai) = {
enable: true;
};
option (pgml.argilla) = {
enable: true;
dataset_name: "imdb_sentiment";
};
option (pgml.lancedb) = {
enable: true;
table_name: "sentiment_examples";
};
option (pgml.mapper_config) = {
enable: true;
};
// Stable row id (Argilla metadata; not a primary UI field).
string id = 1 [
(pgml.argilla_field) = { slot: METADATA; },
(pgml.lancedb_field) = { merge_key: true }
];
// Provenance of the row: "imdb" for seed reviews, "synthetic" for LLM-generated ones.
string source = 2 [(pgml.argilla_field) = {
slot: METADATA;
}];
// Full movie-review text in the style of IMDB user reviews (may include spoilers,
// informal tone, and mixed praise/criticism). Used as the classifier input.
string text = 3 [(pgml.argilla_field) = {
slot: FIELD;
field_type: "text";
required: true;
}];
// Binary sentiment of the review: "negative" or "positive".
string sentiment = 4 [(pgml.argilla_field) = {
slot: QUESTION;
question_type: "label";
labels: ["negative", "positive"];
required: true;
}];
}
// Hyperparameters for a sentiment flywheel training run.
message SentimentTrainConfig {
option (pgml.kind) = RUN_CONFIG;
option (pgml.cli) = {
enable: true;
};
option (pgml.mlflow) = {
enable: true;
experiment_name: "imdb_sentiment";
run_name_field: "run_name";
};
// Human-readable run name (logged as a tracker tag).
string run_name = 1 [
(pgml.default).string = "sentiment",
(pgml.tracking_field) = { slot: TAG }
];
// Number of synthetic examples requested per diversify round.
int32 synthesize_count = 2 [(pgml.default).int32 = 4];
// Extra synthesis rounds that feed prior outputs back as examples.
int32 diversify_rounds = 3 [(pgml.default).int32 = 1];
// OpenAI / Azure deployment name used for synthesis.
string openai_model = 4 [(pgml.default).string = "gpt-4o"];
// TF-IDF + logistic regression holdout fraction.
float test_size = 5 [(pgml.default).float = 0.25];
}
// Holdout metrics from the sentiment classifier.
message SentimentMetrics {
option (pgml.kind) = METRIC_SET;
option (pgml.mlflow) = {
enable: true;
};
float accuracy = 1;
int32 n_train = 2;
int32 n_test = 3;
int32 n_labeled = 4;
}
// Inference request for the online sentiment classifier.
message SentimentPredictRequest {
option (pgml.kind) = FEATURE_ROW;
// Stable sample id (links prediction and feedback rows).
string id = 1;
// Review text to classify.
string text = 2;
}
// Model output for one scored review (separate from the labeled training row).
message SentimentPrediction {
option (pgml.kind) = PREDICTION;
option (pgml.lancedb) = {
enable: true;
table_name: "sentiment_predictions";
};
// Sample id matching SentimentPredictRequest.id.
string sample_id = 1 [(pgml.lancedb_field) = { merge_key: true }];
// Review text that was scored.
string text = 2;
// Predicted sentiment: "negative" or "positive".
string sentiment = 3;
// Model confidence for the predicted class.
float score = 4;
// Deployed model / pipeline version tag.
string model_version = 5;
}
// Human correction / re-label for a scored review.
message SentimentFeedback {
option (pgml.kind) = FEEDBACK;
option (pgml.argilla) = {
enable: true;
dataset_name: "imdb_sentiment_feedback";
};
option (pgml.lancedb) = {
enable: true;
table_name: "sentiment_feedback";
};
// Sample id matching SentimentPrediction.sample_id.
string sample_id = 1 [
(pgml.argilla_field) = { slot: METADATA; },
(pgml.lancedb_field) = { merge_key: true }
];
// Review text shown to the annotator.
string text = 2 [(pgml.argilla_field) = {
slot: FIELD;
field_type: "text";
required: true;
}];
// Model-predicted sentiment (for comparison; not the Argilla question).
string predicted_sentiment = 3 [(pgml.argilla_field) = {
slot: METADATA;
}];
// Corrected sentiment after review: "negative" or "positive".
// When logging a draft from a prediction, set this to the predicted label
// so Argilla records it as a Suggestion.
string sentiment = 4 [(pgml.argilla_field) = {
slot: QUESTION;
question_type: "label";
labels: ["negative", "positive"];
required: true;
}];
// Provenance: "model" for suggestion drafts, "human" after correction.
string source = 5 [(pgml.argilla_field) = {
slot: METADATA;
}];
}
// LitServe / client settings for SentimentClassifier.
message SentimentServeConfig {
option (pgml.kind) = RUN_CONFIG;
option (pgml.litserve_config) = {
enable: true;
service: "SentimentClassifier";
};
string url = 1 [(pgml.default) = {string: "http://localhost:8000"}];
float timeout_s = 2 [(pgml.default) = {float: 30}];
int32 workers_per_device = 3 [(pgml.default) = {int32: 1}];
string accelerator = 4 [(pgml.default) = {string: "cpu"}];
}
// Online-loop metrics (predictions vs human feedback).
message SentimentOnlineMetrics {
option (pgml.kind) = METRIC_SET;
option (pgml.mlflow) = {
enable: true;
};
int32 n_predictions = 1;
int32 n_feedback = 2;
float agreement_rate = 3;
}
// Unary sentiment inference service (LitServe).
service SentimentClassifier {
option (pgml.litserve) = {
enable: true;
};
rpc Predict(SentimentPredictRequest) returns (SentimentPrediction) {
option (pgml.litserve_method) = {
api_path: "/predict";
};
}
}
| Generated module | What you use it for |
|---|---|
sentiment_demo_base.py |
Canonical Pydantic / YAML models |
sentiment_demo_cli_args.py |
Generated Typer CLI flags for SentimentTrainConfig |
sentiment_demo_pydantic_ai.py |
Full + Partial models, synthesize_sentiment_example(_sync) |
sentiment_demo_argilla.py |
Argilla Settings for examples and feedback |
sentiment_demo_lancedb.py |
LanceModels for examples, predictions, feedback |
sentiment_demo_litserve.py |
create_sentiment_classifier_server, predict client |
sentiment_demo_mlflow.py |
Train + online metric helpers |
SentimentExample field layout (offline HITL):
| Field | Use | Argilla slot |
|---|---|---|
id |
Stable row id | METADATA |
source |
imdb or synthetic |
METADATA |
text |
Review body (classifier input) | FIELD |
sentiment |
negative / positive |
QUESTION |
Online contracts (separate messages — do not overload SentimentExample):
| Message | Kind | Notable fields |
|---|---|---|
SentimentPredictRequest |
FEATURE_ROW |
id, text (RPC input) |
SentimentPrediction |
PREDICTION |
sample_id, text, sentiment, score, model_version |
SentimentFeedback |
FEEDBACK |
sample_id, text (FIELD), predicted_sentiment (METADATA), sentiment (QUESTION), source |
SentimentServeConfig |
RUN_CONFIG |
LitServe URL / workers / accelerator |
SentimentOnlineMetrics |
METRIC_SET |
n_predictions, n_feedback, agreement_rate |
Tracking messages use shared (pgml.tracking_field) slots (see
MLflow):
| Message | Kind | Notable fields |
|---|---|---|
SentimentTrainConfig |
RUN_CONFIG |
run_name (TAG), synthesize_count, diversify_rounds, openai_model, test_size (PARAM) |
SentimentMetrics |
METRIC_SET |
accuracy, n_train, n_test, n_labeled |
Proto leading comments on text / sentiment become
Field(description=...) and thus JSON Schema descriptions for synthesis.
Keep them specific—vague comments produce weaker synthetic data
(see PydanticAI).
Enable the generators when you regenerate:
py-gen-ml path/to/sentiment_demo.proto \
--generators=base,patch,sweep,cli_args,pydantic_ai,argilla,lancedb,litserve,mlflow
Setup
From the docs snippets project:
cd docs/snippets
uv sync --extra bridges --extra pydantic-ai --extra argilla --extra lancedb --extra litserve --extra mlflow
bash regenerate.sh # regenerates all snippet protos, including sentiment_demo
OpenAI credentials (required)
The demo talks to OpenAI (Azure OpenAI–compatible endpoint) via PydanticAI. Export:
export OPENAI_API_KEY=...
export OPENAI_ENDPOINT=https://YOUR_RESOURCE.openai.azure.com/
export OPENAI_API_VERSION=2024-12-01-preview
| Variable | Meaning |
|---|---|
OPENAI_API_KEY |
API key |
OPENAI_ENDPOINT |
Azure OpenAI resource endpoint (or compatible base URL) |
OPENAI_API_VERSION |
Azure API version (e.g. 2024-12-01-preview) |
The deployment / model name is SentimentTrainConfig.openai_model (YAML default
gpt-4o, overridable with --openai-model).
Argilla credentials (required to push HITL records)
MLflow uses its normal env / local tracking URI (no cloud account required for a file store).
Seed file (short IMDB-style reviews checked into the repo):
These are not the full Stanford IMDB corpus—they are compact, realistic reviews shaped like IMDB user text so the demo stays fast and license-friendly. Swap in your own seeds (same JSON shape) without changing the proto.
Run the demo
SentimentTrainConfig is populated like CIFAR: YAML base config + generated CLI
overrides via @pgml.pgml_cmd / apply_cli_args.
Default YAML:
# yaml-language-server: $schema=schemas/sentiment_train_config.json
run_name: sentiment
synthesize_count: 4
diversify_rounds: 1
openai_model: gpt-4o
test_size: 0.25
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 | |
cd docs/snippets
uv run python -m snippets.sentiment_flywheel_demo \
--config-paths configs/base/sentiment_train_config.yaml
# override any train-config field:
uv run python -m snippets.sentiment_flywheel_demo \
--config-paths configs/base/sentiment_train_config.yaml \
--synthesize-count 2 --run-name my-run
Example output shape:
{
"n_seeds": 8,
"n_synthetic": 8,
"n_labeled": 16,
"n_argilla_records": 16,
"n_settings_fields": 1,
"n_settings_questions": 1,
"argilla_dataset": "imdb_sentiment",
"openai_model": "gpt-4o",
"run_name": "sentiment",
"lancedb_table": "sentiment_examples",
"train_accuracy": 0.75,
"n_train": 12,
"n_test": 4,
"mlflow_experiment": "imdb_sentiment"
}
Accuracy on this tiny toy set is not meaningful; the point is the wiring from seed → synth → HITL records → store → train → track.
Step-by-step
1. Load IMDB seeds
from snippets.sentiment_flywheel_demo import load_imdb_seeds
seeds = load_imdb_seeds()
assert seeds[0].source == "imdb"
assert seeds[0].sentiment in {"positive", "negative"}
Seeds validate as generated SentimentExample (full) models.
2. Synthesize with few-shot + diversify
from snippets.sentiment_flywheel_demo import SYSTEM_PROMPT, openai_model_from_env
from pgml_out.sentiment_demo_base import SentimentTrainConfig
from pgml_out.sentiment_demo_pydantic_ai import (
SentimentExamplePartial,
synthesize_sentiment_example_sync,
)
train_config = SentimentTrainConfig.from_yaml_files(
["configs/base/sentiment_train_config.yaml"]
)
synthetic = synthesize_sentiment_example_sync(
model=openai_model_from_env(model=train_config.openai_model),
system_prompt=SYSTEM_PROMPT,
count=train_config.synthesize_count,
examples=[SentimentExamplePartial.model_validate(s.model_dump()) for s in seeds],
diversify_rounds=train_config.diversify_rounds,
)
IMDB seeds are passed as few-shot examples. The system prompt asks for
IMDB-style reviews with source="synthetic".
diversify_rounds=1 means: generate a first batch of count rows, then run
another round that feeds prior full outputs back as examples. Total synthetic
rows ≈ count * (diversify_rounds + 1).
3. HITL via Argilla
from snippets.sentiment_flywheel_demo import argilla_client_from_env, push_argilla_dataset
from pgml_out.sentiment_demo_argilla import (
build_sentiment_example_settings,
to_sentiment_example_record,
)
from py_gen_ml.bridges import synthetic_rows_to_argilla_records
client = argilla_client_from_env()
settings = build_sentiment_example_settings(client=client)
records = synthetic_rows_to_argilla_records(
seeds + synthetic,
to_record=to_sentiment_example_record,
)
dataset = push_argilla_dataset(client=client, settings=settings, records=records)
assert {f.name for f in settings.fields} == {"text"}
assert {q.name for q in settings.questions} == {"sentiment"}
Annotators confirm or correct sentiment in the Argilla UI. Labels are logged as
suggestions first; prefer human responses for training once review is done.
4. Store in LanceDB
run_flywheel(train_config, use_lancedb=True) writes labeled rows to a temp LanceDB using the
generated SentimentExample LanceModel and
py_gen_ml.bridges.merge_rows.
In production, keep a durable URI and append only after HITL acceptance.
5. Train
train_sentiment_classifier fits TfidfVectorizer + LogisticRegression and
reports holdout accuracy. Swap this for your real training stack; the dataset
contract stays the same protobuf message.
6. Track with MLflow
run_flywheel logs the same SentimentTrainConfig instance that drove synthesis
and training (from YAML + CLI), plus SentimentMetrics:
from snippets.sentiment_flywheel_demo import log_training_to_mlflow
from pgml_out.sentiment_demo_base import SentimentTrainConfig
from pgml_out.sentiment_demo_mlflow import SentimentMetrics
config = SentimentTrainConfig.from_yaml_files(
["configs/base/sentiment_train_config.yaml"]
)
metrics = SentimentMetrics(accuracy=0.75, n_train=12, n_test=4, n_labeled=16)
log_training_to_mlflow(train_config=config, metrics=metrics)
Typical next steps after offline training:
- Load accepted rows from LanceDB (
load_seeds_from_table) or Argilla exports - Filter
source/ quality metadata - Train / evaluate / push a serving model (see Online loop below)
Online loop
After you have a classifier, the same proto drives serve → prediction → feedback.
cd docs/snippets
uv run python -m snippets.sentiment_online_demo score-batch
# live server (writes predictions to ./sentiment_online.lancedb):
uv run python -m snippets.sentiment_online_demo serve --port 8000
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 | |
Flow:
- Fit TF–IDF + LogReg on IMDB seeds (same helper as the offline demo).
- Score each seed as
SentimentPredictRequest→SentimentPrediction. - Persist predictions with
merge_rowsto thesentiment_predictionstable. - Merge request + prediction into a
SentimentFeedbackdraft (source=model); Argilla gets the predicted label as a Suggestion on the QUESTION field. - Simulate (or apply) human corrections →
source=humanfeedback rows insentiment_feedback. - Log
SentimentOnlineMetrics(n_predictions,n_feedback,agreement_rate) to MLflow experimentimdb_sentiment_online.
SentimentPrediction and SentimentFeedback are separate messages from
SentimentExample. Do not overload the labeled training row for prod scores.
See also LitServe and bridges.serving_argilla.
Completing partial reviews
If you have review text without labels (or the reverse), use Path B
(incomplete):
from snippets.sentiment_flywheel_demo import openai_model_from_env
from pgml_out.sentiment_demo_pydantic_ai import (
SentimentExamplePartial,
synthesize_sentiment_example_sync,
)
filled = synthesize_sentiment_example_sync(
model=openai_model_from_env(model="gpt-4o"),
system_prompt="Fill only missing fields for sentiment examples.",
incomplete=[
SentimentExamplePartial(
id="need-label-1",
source="imdb",
text="An absolute masterpiece of modern cinema.",
sentiment=None,
),
],
)
Only sentiment is sent in the gap JSON Schema; provided fields are preserved.
Do not combine incomplete with diversify_rounds > 0 in one call (v1).
Production checklist
- Field comments on every synthesis-relevant field
- Explicit Argilla
sloton every field (FIELD/QUESTION/METADATA) - Seed set balanced enough for few-shot (both classes)
-
OPENAI_API_KEY/OPENAI_ENDPOINT/OPENAI_API_VERSIONset (offline synth) - HITL: train on human responses, not only LLM suggestions
- Track
source/idin metadata for auditability - Keep predictions and feedback in separate LanceDB tables from labeled examples
- MLflow tracking URI configured for real runs