Ship a product
A product built on Datagoat asks questions for its own customers, on a schedule. A sales tool scores every open deal each morning; a retention tool flags the accounts likely to churn this month; an operations tool ranks the machines most likely to fault this week. Each of them does the same six things:
- Find questions worth trying on each customer's data (
dg_suggest). - Keep each customer apart: their models, their usage (
namespace). - Fit once, with a lifetime you choose (
model_ttl_days). - Score from the model as new cases arrive, without fitting again (
model_ref). - Explain each answer: reasons with ranges, and the pattern.
- Close the loop: report outcomes, watch for drift, renew or delete models.
Every step below runs on the free samples. The numbers quoted come from those runs.
Find the questions
dg_suggest lists the yes/no questions a table could be asked, the ones worth trying first, and
says for each whether it is worth asking. It fits nothing and is free.
POST /v1/suggest
{"data": {"dataset_id": "sample:saas_churn"}, "entity_column": "customer_id", "include_categories": true}
{
"candidates": [
{"outcome_column": "churned", "positive_values": ["yes"], "values": ["no", "yes"], "prevalence": 0.1675,
"worth_asking": true, "defined_by": [],
"question": {"type": "yesno", "outcome_column": "churned", "positive_values": ["yes"]}},
{"outcome_column": "plan", "positive_values": ["basic"], "prevalence": 0.40375, "worth_asking": false, "defined_by": [],
"why_not": "too_few_patterns", "next_step": "The table alone holds too few distinct patterns for this outcome. Add columns …", …},
{"outcome_column": "plan", "positive_values": ["enterprise"], "prevalence": 0.20375, "worth_asking": false, …},
{"outcome_column": "plan", "positive_values": ["pro"], "prevalence": 0.3925, "worth_asking": false, …}
],
"considered": 4,
"not_checked": ["tenure_months", "monthly_charges", "support_tickets", "logins_last_30d", "seats"]
}
- Candidates are the table's two-valued columns (
churned,converted,lapsed). Withinclude_categories, each value of a column with 3 to 8 values is a candidate too (plan = pro). Columns with one value, or more than 8, are listed innot_checked. The entity and time columns are never candidates. worth_askingis the engine's quick check that the table holds enough distinct patterns for the question to have a chance. It is a screen, not an answer. Measured on 267 candidate questions across 15 tables, it passed 60 of the 65 questions thatdg_askwent on to answer, and about half of the questions it passes are answered. Ask the ones it passes; the ask decides. They come first in the list. Call them questions worth trying, not questions Datagoat can answer.why_notandnext_step:too_few_patternsmeans the table alone holds too few distinct patterns for the outcome, andnext_stepsays what could change that: more columns describing each case, or, if you keep an activity log for these cases, asking with it through theeventsorsnapshotsshape. The deals snapshot table alone is refused forwon; read with its activity log, it is answered.defined_bynames columns that on their own almost completely determine the outcome. A question the data already defines is not a prediction: in the study, added_to_cart was defined by cart_value, a telecom account's OnlineSecurity = "No internet service" by InternetService, and a stock's ticker by its price. Such questions often answer, and the answer tells you nothing you didn't know. Readdefined_bybefore building on a question.questionis ready to put indg_ask. Addoutcome_is_desirableyourself: whether an outcome is good news is yours to say, and Datagoat never fills it in.
It works on tables. For an event log, a series or another shape, read it with the shape once, or ask the question you have in mind.
One namespace per customer
namespace keeps a customer's models and usage apart from everyone else's in your workspace:
{"data": {"dataset_id": "ds_…"}, "namespace": "acme", "entity_column": "deal_id", …}
- A model fitted in
acmeanswers only inacme. Asking about it asglobexis refused (namespace_mismatch). - Every later call that names a
model_ref(amodel_refquestion,dg_report_outcomes,dg_drift,dg_attest,dg_evidence,dg_extend_model,dg_delete_model) finds the model's namespace itself. You can leavenamespaceout of those calls. - Usage is recorded per namespace, so you can see what each customer's questions cost.
- The same bytes in two namespaces are two models and two fits. That is the point: nothing learned from one customer's data answers for another.
A namespace is 1 to 64 characters: lowercase letters, digits, - and _. Use your own customer
id. Omit it for your workspace's own questions.
Fit once
The first ask on a record fits a model. Choose how long it keeps answering with model_ttl_days,
from 1 to 365 (the default is 90):
{"data": {"dataset_id": "ds_…"}, "namespace": "acme", "model_ttl_days": 180,
"entity_column": "deal_id", "subject_kind": "org",
"questions": {"won": {"type": "yesno", "outcome_column": "won", "outcome_is_desirable": true}},
"cases": {"ids": ["deal_0001"]}}
Every answer says when its model expires: answers.won.model_expires_at. Keep the model_ref.
Score from the model
A question can name a model_ref instead of an outcome. Nothing is fitted: the model scores the
cases, with the same reasons, ranges and pattern match it gives when you send its record. There
are two ways to send the cases.
New cases as rows, with no record. Send rows with the columns the model uses (rows are your own data, so this needs a live key; a test key scores from the model against a sample record):
POST /v1/ask
{"entity_column": "customer_id", "subject_kind": "org",
"questions": {"churn": {"type": "yesno", "model_ref": "mr1_…"}},
"cases": {"rows": [{"customer_id": "new_1", "tenure_months": 3, "support_tickets": 9, …}]}}
A newer record, cases by id. Send today's table (or event log, with its shape), and name the
cases. The model reads them from it. The table doesn't need the outcome column, and a rank
question with no cases ranks every row:
{"data": {"dataset_id": "ds_today"}, "entity_column": "customer_id", "subject_kind": "org",
"questions": {"top": {"type": "rank", "model_ref": "mr1_…", "top_k": 20}}}
Both answer with fits_run: 0: one decision per answered case, no fit. The model's outcome, its
yes values and outcome_is_desirable are its own, so a model_ref question takes none of them.
choice is not answered from a model (its options are checked against the record).
Two things to know:
- A reason compares the case with the average case in the record the model learned from, not today's record. Two answers from one model are comparable over time for this reason.
- A model fitted before models kept their explanations answers
409 model_needs_record. Ask once with its record and use themodel_refthat answer returns. If the engine is unchanged since the old fit, it is the same model and nothing fits again; after an engine release, it is a new model.
What the model reads
Every yesno, score and rank answer lists model_columns: each column a case must carry for
its model, in your column names (a column the engine derived, such as a tier of seats, is listed
as seats). A scheduled job sends those. A case missing some is refused with all of them named at
once:
{"code": "row_not_scoreable",
"detail": "the cases lack 2 column(s) the model reads: plan, seats (the model reads: plan, seats, tenure_months)"}
With an events or snapshots record, the columns come from the reading: a count per activity
type, per window. Which types get a column was chosen when the model was fitted, from the whole
record. A newer record asked about by model_ref is read with the fit's own types, so a
small daily batch works: a deal with no meetings today still gets meeting_count_30d, counted 0,
and a type the model never read gets no column. Datagoat records each model's types when it fits,
and sends them with every later reading; nothing is needed from you.
A model fitted before 2026-09-24 has no recorded types, and a small batch can still build other
columns than it reads. Ask once with its full record (the fit is reused: fits_run: 0 unless the
engine changed since), and Datagoat records them from then on.
Model lifetime
Why models expire at all: a model is derived from your customer's data. It holds cut points and the category values it groups (never rows), and a lifetime bounds how long that derived data is kept: within a day of expiry it is deleted, and only the model's outcome record remains. A model is also a snapshot of the world at the time it was fitted, and an expiry makes an old one stop answering rather than answer silently. 90 days is the default because it matches a quarter and the signing-key rotation. Nothing in the statistics requires it.
| You want | Do |
|---|---|
| a model that lives longer or shorter | model_ttl_days, 1 to 365, on the ask that fits it |
| to keep a model answering longer | dg_extend_model with days (1 to 365 from now), before it expires: an expired model is not revived |
| to remove a model now (a customer leaves, an erasure request) | dg_delete_model. It needs the "Can report outcomes" capability, because it also ends the model's outcome record and evidence. |
| a fresh model on new outcomes | ask with the new record, and refit_of the old model_ref for drift |
model_ttl_days only applies when a fit runs. The same bytes asked again reuse the cached model,
which keeps its own expiry, and the answer's model_expires_at shows it. Use dg_extend_model to
change an existing model's lifetime.
A longer lifetime saves fits and keeps scores comparable, but the world moves: report outcomes and
read dg_drift, and refit when the record has new outcomes.
Drift decides whether a model is still good; the lifetime is a privacy limit and a safety net for
models nobody checks. A monitored product refits on new data on a schedule with refit_of, then acts
on dg_drift: keep → dg_extend_model and carry on; refit → switch to the new model_ref;
abandon → stop using the question. no_check_yet means nothing has been compared (the model was
never refitted): refit first, and never renew on it. Set the lifetime longer than the refit cycle.
Cases seen at several moments
Many products score the same case more than once: a deal at each stage, an account every month. Two things matter:
- Build each moment's features from what was known then. The snapshots shape reads an event log as of each snapshot you list, and no event after a snapshot is counted in it.
- Hold out whole cases. Several rows of one case share its outcome. If the same deal sits on
both sides of the held-out check, the check grades the model partly on deals it has seen.
snapshotsholds out whole cases for you. For a table you built yourself, name the case withgroup_column.
On the deals sample (1,200 deals entering three stages each), the snapshots answer holds out the
most recent deals, whole (validation_scheme: out_of_time, held_out: {"whole_cases_by": "deal_id"}),
and its pattern is inbound_count_30d over 6, meeting_count_30d over 0, amount 12000 or less,
inbound_count_7d over 1. Every answer from a model fitted this way carries held_out, so a
product can say its accuracy was measured on deals the model never saw.
Explain every answer
Each case carries up to four reasons, each with the range its value fell in, and each answer
carries the pattern with its cut points and each case's pattern_match. They are how a product
shows why: "inbound_count_30d 11 or more, amount under 18000, meeting_count_30d 2 or more". See
Answers and Patterns. Reasons are associations, not
causes.
Close the loop
dg_report_outcomes: what really happened to cases you scored. It builds the model's track record and feedsdg_evidence.dg_attestanddg_evidence: record the actions you took through a case's levers, then compare cases acted on with cases not acted on.dg_drift: on a refit (refit_of), whether the pattern moved: keep, refit or abandon, orno_check_yetwhen nothing has been compared.
Different kinds of data
Everything above works for every shape. What "score later" means differs:
| Shape | Fit on | Score later with a model_ref |
|---|---|---|
table |
the table | new rows, or a newer table and ids |
events |
the log, read at as_of |
a newer log (same shape) and ids: each case read at the newer log's end |
series |
the series | a newer series and ids: each case read at its latest period |
panel |
the panel | a newer panel and ids |
signals |
the readings | newer readings and ids: each case at its latest snapshot |
traces |
the run log | a newer run log and ids |
snapshots |
the log and the snapshot table | a newer log and snapshot table (outcome blank) and snapshot ids |
Each row of this table was run on its sample: the model_ref answer matched the fit's chances,
reasons and pattern match exactly, with no fit. With a shape, send the same shape settings as the
fit: the model reads the features that shape builds, and a different window builds different
features. For events and snapshots, the activity types are the fit's own
(What the model reads). Rows (cases.rows) work for a table,
or when you send the features the reading built yourself.
A scheduled product, end to end
from datagoat import Client, yesno, from_model, snapshots
dg = Client()
for customer in customers: # your customers
ns = customer.id # one namespace each
if customer.model_ref is None or customer.has_new_outcomes:
fit = dg.ask({"won": yesno("won", outcome_is_desirable=True)},
dataset_id=customer.history, entity_column="deal_id", subject_kind="org",
time_column="ts", shape=customer.shape, namespace=ns, model_ttl_days=180,
cases={"ids": [customer.any_snapshot]})
customer.model_ref = fit["answers"]["won"]["model_ref"]
today = dg.ask({"won": from_model(customer.model_ref, type="rank", top_k=50)},
dataset_id=customer.today, entity_column="deal_id", subject_kind="org",
time_column="ts", shape=customer.shape)
show(today["answers"]["won"]["ranked"]) # chances, reasons with ranges, pattern_match
today runs no fit. When outcomes arrive, dg.report_outcomes(customer.model_ref, […]). When a
customer leaves, dg.delete_model(customer.model_ref).
Next
- Gate the actions your product takes: Put it in an agent.
- Keep each model honest over time: Run it.