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:

  1. Find questions worth trying on each customer's data (dg_suggest).
  2. Keep each customer apart: their models, their usage (namespace).
  3. Fit once, with a lifetime you choose (model_ttl_days).
  4. Score from the model as new cases arrive, without fitting again (model_ref).
  5. Explain each answer: reasons with ranges, and the pattern.
  6. 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). With include_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 in not_checked. The entity and time columns are never candidates.
  • worth_asking is 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 that dg_ask went 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_not and next_step: too_few_patterns means the table alone holds too few distinct patterns for the outcome, and next_step says what could change that: more columns describing each case, or, if you keep an activity log for these cases, asking with it through the events or snapshots shape. The deals snapshot table alone is refused for won; read with its activity log, it is answered.
  • defined_by names 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. Read defined_by before building on a question.
  • question is ready to put in dg_ask. Add outcome_is_desirable yourself: 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 acme answers only in acme. Asking about it as globex is refused (namespace_mismatch).
  • Every later call that names a model_ref (a model_ref question, dg_report_outcomes, dg_drift, dg_attest, dg_evidence, dg_extend_model, dg_delete_model) finds the model's namespace itself. You can leave namespace out 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 the model_ref that 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. snapshots holds out whole cases for you. For a table you built yourself, name the case with group_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 feeds dg_evidence.
  • dg_attest and dg_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, or no_check_yet when 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