Mathematical model
The purpose of the model is to transform product behavior into an explainable risk score using business-defined signals.
The core pipeline is:
event history -> signal intensities -> signal impacts -> raw score -> risk scoreEach step is intentionally simple and inspectable.
1. Signals normalize behavior into intensity
Every signal returns an intensity between 0 and 1.
0 = inactive
1 = fully activeThis normalization is what lets very different product concepts share the same scoring system.
For example:
- “recent billing failure”;
- “no app open recently”;
- “low usage”;
- “strong usage”;
all become the same mathematical shape:
intensity ∈ [0, 1]2. Intensity becomes impact
Once a signal has an intensity, its contribution is linear:
impact = weight * intensityA positive weight increases risk.
A negative weight reduces risk.
That means the same model can represent both churn signals and retention signals.
billing failure -> positive weight
strong usage -> negative weight3. Impacts become score
The score is additive:
rawScore = baseRisk + sum(signal impacts)
riskScore = clamp(rawScore, 0, 100)rawScore is allowed to go below 0 or above 100.
riskScore is the bounded display score.
This distinction matters. If rawScore is 132, the displayed risk is still 100, but the raw score tells you the user is far beyond the critical threshold.
4. Score becomes risk level
Risk levels are derived from the clamped riskScore.
riskScore < medium -> low
riskScore < high -> medium
riskScore < critical -> high
otherwise -> criticalThe thresholds are configurable, but they must remain ordered:
medium < high < criticalThis keeps the score numeric while still giving applications a simple label to display or use in workflows.
5. Time is modeled with half-life decay
Tiaude uses exponential decay:
decay(ageDays, halfLifeDays) = 2 ^ (-ageDays / halfLifeDays)This gives a natural product interpretation:
age = 0 -> contribution is 1
age = one half-life -> contribution is 0.5
age = two half-lives -> contribution is 0.25
age = three half-lives -> contribution is 0.125Half-life is easier to reason about than arbitrary decay constants.
A developer can configure:
halfLifeDays: 7and understand that the signal loses half of its strength every seven days.
Signal family: occurrence
An occurrence signal measures the remaining strength of the latest matching event.
intensity = decay(age since latest matching event, halfLifeDays)If no event ever matched, intensity is 0.
Use it for events that are important when they happen, then fade over time.
Examples:
- billing failure;
- support complaint;
- failed import;
- cancellation intent.
A billing failure from one hour ago should matter more than a billing failure from three months ago. occurrence captures that directly.
Signal family: absence
An absence signal measures the lack of a recent expected event.
intensity = 1 - decay(age since latest matching event, halfLifeDays)If no event ever matched, intensity is 1.
This is useful for inactivity-style risk.
Examples:
- no app open;
- no activation event;
- no project created;
- no core workflow completed.
Immediately after an app open, inactivity risk is low. As that event gets older, the absence signal rises smoothly.
This is one of the useful symmetries in the model:
occurrence = presence curve
absence = complement of presence curveSignal family: frequency_above
A frequency_above signal measures repeated recent usage.
It uses a decayed count instead of a raw count.
Linear mode:
intensity = min(decayedCount / threshold, 1)Binary mode:
intensity = decayedCount >= threshold ? 1 : 0With a negative weight, this becomes a retention signal.
Example:
strong recent feature usage -> lower churn riskSignal family: frequency_below
A frequency_below signal measures insufficient recent usage.
Linear mode:
intensity = 1 - min(decayedCount / threshold, 1)Binary mode:
intensity = decayedCount < threshold ? 1 : 0This is useful when risk should decrease as usage approaches the desired threshold.
Example:
less than 3 recent core actions -> higher churn riskThe linear mode avoids hard cliffs. A user close to the threshold is treated differently from a user with no recent usage at all.
Why frequency uses decayed count
A strict time window like “events in the last 30 days” requires keeping event timestamps or replaying history.
Tiaude instead uses a decayed accumulator.
When time advances:
decayedCount = decayedCount * decay(deltaDays, halfLifeDays)When a matching event arrives:
decayedCount = decayedCount + 1That gives the model a compact memory of recent activity.
Older events never disappear abruptly. They fade continuously.
Confidence is not risk
confidence measures how much relevant evidence the scorer has seen.
It is not the probability that the user will churn.
confidence =
min(1, log(1 + relevantEventCount) / log(1 + targetRelevantEvents))The logarithm is intentional.
The first few relevant events add a lot of confidence. Later events still help, but with diminishing returns.
This avoids treating a score based on one relevant event as equally reliable as a score based on many relevant events.
Freshness is not computation time
freshness measures the age of the latest relevant event.
It is based on:
lastRelevantEventAtnot only on:
computedAtThat distinction matters.
A score can be recomputed today and still be stale if the last relevant user event happened months ago.
computedAt = today
lastRelevantEventAt = 90 days ago
freshness = staleThis prevents refreshScore() from creating a false sense of fresh data.
Reasons and actions
A signal is considered active when:
intensity > 0Active signals produce reasons.
Recommended actions are only produced by active signals that increase risk:
impact > 0Negative-weight signals can explain why risk is lower, but they do not produce recommended actions.
This keeps the output practical:
- reasons explain the score;
- actions suggest what to do next.
The model in compact form
The full scoring model can be summarized as:
for each signal:
intensity = signal-specific function(state, now)
impact = weight * intensity
rawScore = baseRisk + sum(impact)
riskScore = clamp(rawScore, 0, 100)
riskLevel = thresholds(riskScore)
confidence = evidence(relevantEventCount)
freshness = age(lastRelevantEventAt)