Most user behavior data gets handled as a “what” question. Did a user cancel their subscription? Did they hit the API limit? Those answers are useful, but they usually arrive too late to act on. A binary outcome leaves out the variable you actually need, which is timing.
Discrete time-to-event modeling changes the question. Instead of asking if something will happen, you ask when. On a WooCommerce membership site or a SaaS platform running on WordPress, that is the difference between a support team reacting to cancellations and a system that flags them in advance.
Why discrete time over continuous?
Time is continuous in theory. WordPress data almost never is. WooCommerce Subscriptions renew on a monthly cycle, invoices have due dates, and cron jobs fire on intervals. Modeling all of that as continuous time adds math you do not need and rarely buys you any accuracy.
Discrete modeling fits best when:
- Events land on specific dates, like payment cycles.
- Your granularity is fixed, so churn gets checked once a day.
- Exact timing is impossible to capture anyway, since you only know a site went down somewhere between two health checks.
The censoring trap
On one project we tried to predict “time to developer burnout” from ticket activity, and the numbers came out wrong because we had not accounted for right censoring. Censoring happens when the event has not occurred yet. A developer who is still active tells you nothing about when they will burn out, only that they have not. Someone who quits for unrelated reasons is censored too, because the data stopped, not because they were “healed.”
Ignore censoring and the model picks up a heavy bias: it assumes events happen less often than they really do. The usual fix is a life table.
The life table logic
A life table cuts time into intervals. Rather than waiting out a year-long contract before you learn anything, you learn from month one, then month two, and so on. I walked through what that looks like in practice in an earlier post on practical survival analysis in Python.
Each row is one interval, and each row tracks three numbers:
- Units at risk is everyone who has not had the event yet.
- Events is how many of them had it during this interval.
- Censored covers the ones who left the study or have not hit the event yet.
Implementing hazard logic in PHP
Heavy modeling probably belongs in Python, but the basic life table calculation is small enough to run inside a custom plugin and surface a health score right in the dashboard.
<?php
/**
* Calculate the hazard rate (conditional probability) for a time interval.
*
* @param int $units_at_risk Total units starting the interval.
* @param int $events Number of events occurred.
* @param int $censored Number of censored units.
* @return float
*/
function bbioon_calculate_hazard_rate( $units_at_risk, $events, $censored ) {
// Adjust at-risk units for censoring (assuming they were censored at start)
$adjusted_at_risk = $units_at_risk - $censored;
if ( $adjusted_at_risk <= 0 ) {
return 0.0;
}
return (float) $events / $adjusted_at_risk;
}
// Example usage for Month 1 of a subscription cohort
$hazard_m1 = bbioon_calculate_hazard_rate( 1283, 150, 20 );
echo "Monthly Hazard Rate: " . round( $hazard_m1 * 100, 2 ) . "%";
That gives you the hazard: the probability of the event landing in this interval, given that the user made it that far. The arithmetic is trivial, and the rest of discrete time-to-event modeling is built on top of it.
If this kind of modeling is eating your dev hours, I can take it off your plate. I have been wrestling with WordPress since the 4.x days.
Moving from binary flags to timing
Switching from binary classification to a timing model changes what your retention tooling can actually do. Cut the data into intervals, account for censoring, and the predictions stop flattering themselves. If you want more on scaling the data layer underneath, I wrote up robust historical data analysis.