On this page
Use casesPersonalization by segment
Personalization by segment
You have three checkout treatments. Express suits a small basket on a phone, installments suit a large considered basket, and a returning customer responds to neither. No single one wins everywhere, and the usual answer is a stack of targeting rules that somebody wrote once and nobody has revisited since.
The alternative is to describe each visitor to qbrix and let it learn the segmentation itself. This is the same loop as checkout conversion, with one addition: you declare what a visitor looks like when you create the experiment, and send named values on every request.
Where this fits
Use it when you have evidence that different visitors want different things, and facts available at request time that plausibly explain the difference.
Contextual learning needs more data than a global answer does, because it is fitting a separate response per region of your feature space rather than one number per variant. If a global experiment on the same surface has not converged yet, a contextual one will not either. Run checkout conversion first, confirm the loop works, then add the properties.
If the segments are genuinely known and stable — enterprise versus self-serve, say — a feature gate rule is simpler, cheaper and completely predictable. Reach for context when you suspect the segmentation exists but cannot write it down.
The modelling decisions
| Decision | This recipe |
|---|---|
| Arm | One checkout treatment — standard, express, installments. |
| Reward | 1.0 if the visitor converted, 0.0 if not. |
| Reward type | binary |
| Context | A declared schema, and named properties per request. |
Properties and metadata are not the same field
This is the distinction that costs people the most time. Both live on the context object; they go to different places.
| Field | Who reads it | Shape |
|---|---|---|
id | Feature gates, for deterministic bucketing | Any stable string. Required. |
properties | The policy. This is what the model learns on. | Named values, matching the declared schema. |
metadata | Feature gate rules only. The policy never sees it. | Free-form JSON. |
Putting {"device": "mobile"} in metadata and expecting the model to personalize on it does nothing. It has to be a property, and the schema has to declare it.
Set up the experiment
Create the pool
pool = client.pool.create(
name="checkout-treatments",
arms=[
{"name": "standard"},
{"name": "express"},
{"name": "installments"},
],
)Declare what a visitor looks like
Decide the property list before you create the experiment — the schema is fixed for its life, the same constraint as arms, for the same reason.
CONTEXT_SCHEMA = [
{"type": "categorical", "name": "device", "values": ["mobile", "desktop"]},
{"type": "categorical", "name": "country", "values": ["US", "DE", "TR"]},
{"type": "numeric", "name": "cart_value", "min": 0, "max": 500},
{"type": "boolean", "name": "returning"},
]Create the experiment
experiment = client.experiment.create(
name="checkout-personalization",
pool_id=pool.id,
policy="auto",
policy_params={
"reward_type": "binary",
"context_schema": CONTEXT_SCHEMA,
},
)A declared schema is itself the request for a contextual strategy — use_context and dim are both implied by it, and passing dim yourself is an error. With auto you get both contextual and non-contextual learners competing, which is useful in itself: if the non-contextual ones keep winning, your properties are not carrying signal.
"LinTSPolicy" or "LinUCBPolicy" are the single-learner contextual choices if you would rather pick one — see Policies.
Describe the visitor
There is no encoding step. You send the values you already have, under the names you declared.
def visitor(request) -> dict:
return {
"device": "mobile" if request.is_mobile else "desktop",
"country": request.country,
"cart_value": request.cart_value,
"returning": request.is_returning,
}function visitorProperties(req: VisitorRequest) {
return {
device: req.isMobile ? "mobile" : "desktop",
country: req.country,
cartValue: req.cartValue,
returning: req.isReturning,
};
}The one-hot slots, the normalization and the width are the server's problem. What used to be three rules about building a vector is now two about declaring a schema:
Declare numeric ranges where your traffic actually sits. A cart_value declared 0–500 whose real traffic sits between 10 and 30 compresses the signal into 4% of the range. Declare 10–30 and let the clamp absorb the tail — outliers are clamped, not rejected.
Start with three to six properties. Contextual learners invert a matrix per arm on every selection; more width means slower learning and more work per request. Add properties when the ones you have prove insufficient, not in advance. A high-cardinality property like country is where this bites — bucket it rather than enumerating 190 values.
A property name the schema never declared is rejected, with 400 INVALID_CONTEXT_PROPERTIES naming it and listing what the schema does declare. {"devise": "mobile"} fails the call.
That is deliberate. The alternative is worse: a typo would encode to defaults on every request, the model would train against a constant vector, and nothing would tell you until allocation looked flat weeks later.
A value you never declared is a different case and is absorbed, not rejected — a country of "JP" against the schema above is scored into a reserved other slot. Your traffic is allowed to surprise you.
Select on the request path
The key stays on your server, so the browser asks your handler and your handler asks qbrix.
- BrowserPOST /api/checkout { visitorId }
- Your route handlerreads what it knows
- qbrix selectencodes, returns a variant + request_id
- Rendered checkouttreatment back to the client
from qbrix import Context
result = client.agent.select(
experiment_id=EXPERIMENT_ID,
context=Context(
id=visitor_id,
properties=visitor(request),
# metadata is for gate rules — the model does not read it
metadata={"plan": request.plan},
),
)
treatment = result.arm.nameconst { arm, requestId } = await qbrix.select(EXPERIMENT_ID, {
id: visitorId,
// named values, sent as they are. numbers stay numbers — cartValue is
// declared numeric server-side and normalised against its range there,
// so stringifying it here would fail the encode.
properties: visitorProperties(req),
});Close the loop
Same as any binary experiment — persist the request_id, report the outcome when it happens, and send the zeros as well as the ones.
if result.request_id is not None:
session["qbrix_request_id"] = result.request_id
# later, when the visitor converts or the session ends
client.agent.feedback(request_id=session["qbrix_request_id"], reward=1.0)if (requestId) session.qbrixRequestId = requestId;
await qbrix.feedback(session.qbrixRequestId, 1.0);Do not recompute properties at feedback time. The encoded vector is recorded with the selection; feedback refers to it by id. Recomputing is unnecessary and, if it differs, misleading to you when you read the logs later.
What you should see
First, a global preference — one variant ahead on average, exactly as a non-contextual experiment would show.
Then, if your properties carry signal, the split stops being global. Different segments settle on different treatments. This is the payoff, and it takes noticeably longer than the global answer.
If it never happens, that is a real finding rather than a failure: either the properties do not predict variant preference, or the variants genuinely work equally well for everyone. Both are worth knowing, and both are cheaper to learn this way than by writing targeting rules on a hunch.
Fire a few thousand selections through a script in one go and the split will look stubbornly even. That is the cache, not the learner: the whole run finished inside a single parameter window, so every selection saw the same snapshot.
Measured on a real run — three variants, true rates 3.1% / 5.2% / 3.8% — the first 2,500 selections split 33 / 33 / 34. After a pause long enough for the parameters to refresh, the next 2,500 came back 7 / 58 / 34. The learner had known the answer the whole time.
If you are testing by script, pace the traffic over a longer window or run it in two phases a few minutes apart. Over real traffic, spread across hours, none of this is visible.
Four things to get right
Fix the property list at creation. The schema is bound to the experiment, and its width is baked into the learned parameters. A fifth property means a new experiment — the same constraint as arms, for the same reason. Changing or omitting the schema on an update returns 409 CONTEXT_SCHEMA_IMMUTABLE; to tune alpha, resend the schema unchanged alongside it.
Send the values raw. Numbers stay numbers. cart_value is declared numeric and normalized server-side, so sending "62.5" as a string is a 400, not a convenience.
Do not declare anything you would not want to defend. Properties derived from protected characteristics — or close proxies for them — turn a personalization experiment into a discrimination problem, whatever your intent. Stick to behaviour and context.
Set policy parameters at creation. They are read when the learner initializes, so policy_params edits on a running experiment take effect after a reset rather than immediately.
Next
- Contexts — the full reference on schemas, properties and the raw-vector escape hatch
- Feature gates — when a written rule beats a learned one
- Pricing & discounts — the same contextual machinery on a continuous reward