*/ public static function groups() { return array( 'champions' => array( 'label' => __( 'Champions', 'statbird' ), 'tone' => 'good', 'about' => __( 'Bought recently and often. Your best customers.', 'statbird' ), ), 'loyal' => array( 'label' => __( 'Loyal', 'statbird' ), 'tone' => 'good', 'about' => __( 'Buy regularly, a little less recently than Champions.', 'statbird' ), ), 'potential' => array( 'label' => __( 'Potential loyalist', 'statbird' ), 'tone' => 'good', 'about' => __( 'Recent customers with a second or third order. Close to becoming regulars.', 'statbird' ), ), 'promising' => array( 'label' => __( 'Promising', 'statbird' ), 'tone' => 'neutral', 'about' => __( 'Bought once, fairly recently.', 'statbird' ), ), 'new' => array( 'label' => __( 'New', 'statbird' ), 'tone' => 'neutral', 'about' => __( 'First order, very recently.', 'statbird' ), ), 'attention' => array( 'label' => __( 'Needs attention', 'statbird' ), 'tone' => 'neutral', 'about' => __( 'Middling recency and only one or two orders. Could go either way.', 'statbird' ), ), 'at_risk' => array( 'label' => __( 'At risk', 'statbird' ), 'tone' => 'risk', 'about' => __( 'Used to buy a few times, but not for a while.', 'statbird' ), ), 'cant_lose' => array( 'label' => __( "Can't lose", 'statbird' ), 'tone' => 'risk', 'about' => __( 'Frequent buyers who have gone quiet. The most valuable to win back.', 'statbird' ), ), 'hibernating' => array( 'label' => __( 'Hibernating', 'statbird' ), 'tone' => 'lost', 'about' => __( 'Few orders, long ago.', 'statbird' ), ), 'lost' => array( 'label' => __( 'Lost', 'statbird' ), 'tone' => 'lost', 'about' => __( 'One order, a long time ago.', 'statbird' ), ), ); } /** * Group for a recency and frequency score. * * @param int $r Recency score 1-5. * @param int $f Frequency score 1-5. * @return string Group key. */ public static function group_for( $r, $f ) { $grid = array( 5 => array( 1 => 'new', 2 => 'potential', 3 => 'potential', 4 => 'champions', 5 => 'champions', ), 4 => array( 1 => 'promising', 2 => 'potential', 3 => 'loyal', 4 => 'champions', 5 => 'champions', ), 3 => array( 1 => 'attention', 2 => 'attention', 3 => 'loyal', 4 => 'loyal', 5 => 'loyal', ), 2 => array( 1 => 'hibernating', 2 => 'at_risk', 3 => 'at_risk', 4 => 'cant_lose', 5 => 'cant_lose', ), 1 => array( 1 => 'lost', 2 => 'hibernating', 3 => 'hibernating', 4 => 'cant_lose', 5 => 'cant_lose', ), ); $r = max( 1, min( 5, (int) $r ) ); $f = max( 1, min( 5, (int) $f ) ); return $grid[ $r ][ $f ]; } /** * Cut points that split values into $parts equal-sized bands. * * @param float[] $values Values (any order). * @param int $parts Number of bands. * @return float[] $parts - 1 ascending cut points. */ public static function cutpoints( array $values, $parts ) { sort( $values ); $n = count( $values ); $out = array(); for ( $k = 1; $k < $parts; $k++ ) { $out[] = $n ? (float) $values[ (int) min( $n - 1, floor( $k * $n / $parts ) ) ] : 0.0; } return $out; } /** * Recency cut points in days, anchored to the store's own buying rhythm. * * Ranking recency against other customers would mean nobody's group changes when the * whole store goes quiet. Anchoring to the usual gap between orders means a customer * drifts towards At risk on their own timeline: 5 within half the usual gap, 4 within * the gap, 3 within twice it, 2 within four times it, 1 beyond. * * @param float $store_gap Typical days between orders. * @return float[] */ public static function recency_cuts( $store_gap ) { $g = max( 7.0, (float) $store_gap ); return array( round( $g * 0.5, 2 ), round( $g, 2 ), round( $g * 2, 2 ), round( $g * 4, 2 ) ); } /** * Band (1-based) a value falls in, given ascending cut points. * * @param float $v Value. * @param float[] $cuts Cut points. * @return int */ public static function band( $v, array $cuts ) { $b = 1; foreach ( $cuts as $c ) { if ( $v >= $c ) { ++$b; } } return $b; } /** * Recency, frequency and monetary scores for one customer. * * @param float $recency Days since last purchase. * @param int $orders Purchases. * @param float $spent Net spend. * @param array $cuts Cut points from run(). * @return int[] Array of r, f, m. */ public static function rfm( $recency, $orders, $spent, array $cuts ) { // Fewer days since the last order is better, so the band is reversed. $r = 6 - self::band( $recency, $cuts['r'] ); // One purchase is always 1; repeat customers are spread over 2-5 among themselves. $f = $orders <= 1 ? 1 : 1 + self::band( $orders, $cuts['f'] ); $m = self::band( $spent, $cuts['m'] ); return array( max( 1, min( 5, $r ) ), max( 1, min( 5, $f ) ), max( 1, min( 5, $m ) ) ); } /** * Projected spend in the next 12 months. * * Buying rate so far (orders per year, measured over at least 90 days) times average order * value, times the chance they are still a customer. * * @param int $orders Purchases. * @param float $spent Net spend. * @param float $tenure Days since first purchase. * @param float $risk Churn probability 0-1. * @return float */ public static function value_12m( $orders, $spent, $tenure, $risk ) { if ( $orders < 1 ) { return 0.0; } $per_year = $orders / max( 90.0, (float) $tenure ) * 365; $aov = $spent / $orders; return round( $per_year * $aov * ( 1 - max( 0, min( 1, $risk ) ) ), 2 ); } /** * Hooks. */ public static function init() { add_action( self::HOOK, array( __CLASS__, 'run' ) ); add_action( self::NIGHTLY, array( __CLASS__, 'nightly' ) ); add_action( 'statbird_rebuild_batch_done', array( __CLASS__, 'after_rebuild' ) ); add_action( 'statbird_customer_refreshed', array( __CLASS__, 'score_one' ), 10, 2 ); add_action( 'init', array( __CLASS__, 'schedule_nightly' ), 30 ); } /** * Makes sure the nightly re-rank is scheduled (02:30 site time). */ public static function schedule_nightly() { if ( ! function_exists( 'as_has_scheduled_action' ) || as_has_scheduled_action( self::NIGHTLY, array(), Rebuild::GROUP ) ) { return; } $next = strtotime( 'tomorrow 02:30', current_time( 'timestamp' ) ) - (int) ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- converting a local wall-clock time to UTC. as_schedule_recurring_action( $next, DAY_IN_SECONDS, self::NIGHTLY, array(), Rebuild::GROUP ); } /** * Nightly: re-rank everyone (recency changes every day even without orders), retrain. */ public static function nightly() { self::run( true ); } /** * Scores everyone once a rebuild finishes. * * @param array $state Rebuild state. */ public static function after_rebuild( $state ) { if ( 'complete' !== $state['status'] ) { return; } if ( function_exists( 'as_enqueue_async_action' ) && ! ( defined( 'WP_CLI' ) && WP_CLI ) ) { as_enqueue_async_action( self::HOOK, array( true ), Rebuild::GROUP ); } else { self::run( true ); } } /** * Stored cut points, store gap and model. * * @return array */ public static function state() { $s = get_option( self::OPTION, array() ); return is_array( $s ) ? $s : array(); } /** * Scores every customer. * * @param bool $retrain Retrain the churn model first. * @param int $now Unix time to treat as today (tests). * @return array Summary. */ public static function run( $retrain = true, $now = 0 ) { global $wpdb; $now = $now ? (int) $now : time(); $table = Store::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name only. $rows = (array) $wpdb->get_results( "SELECT customer_key, first_order, last_order, orders, spent, avg_gap_days, segment FROM $table", ARRAY_A ); if ( ! $rows ) { update_option( self::OPTION, array( 'scored' => gmdate( 'Y-m-d H:i:s', $now ), 'customers' => 0, ), false ); return array( 'customers' => 0 ); } $rec = array(); $repeat = array(); $spent = array(); $gaps = array(); foreach ( $rows as $i => $r ) { $rows[ $i ]['recency'] = max( 0, ( $now - strtotime( $r['last_order'] . ' UTC' ) ) / DAY_IN_SECONDS ); $rows[ $i ]['tenure'] = max( 0, ( $now - strtotime( $r['first_order'] . ' UTC' ) ) / DAY_IN_SECONDS ); $rec[] = $rows[ $i ]['recency']; $spent[] = (float) $r['spent']; if ( (int) $r['orders'] > 1 ) { $repeat[] = (int) $r['orders']; $gaps[] = (float) $r['avg_gap_days']; } } // The store's typical rhythm: median gap of repeat customers, 60 days if there are none yet. sort( $gaps ); $store_gap = $gaps ? max( 7.0, (float) $gaps[ (int) floor( count( $gaps ) / 2 ) ] ) : 60.0; $cuts = array( 'r' => self::recency_cuts( $store_gap ), 'f' => self::cutpoints( $repeat ? $repeat : array( 2 ), 4 ), 'm' => self::cutpoints( $spent, 5 ), ); $prev = self::state(); $fit = $prev['fit'] ?? null; if ( $retrain || ! $fit ) { $fit = Model::fit( $store_gap, $now ); } $state = array( 'scored' => gmdate( 'Y-m-d H:i:s', $now ), 'customers' => count( $rows ), 'cuts' => $cuts, 'store_gap' => $store_gap, 'fit' => $fit, 'method' => ! empty( $fit['use'] ) ? 'model' : 'rule', ); update_option( self::OPTION, $state, false ); $values = array(); $changes = array(); $stamp = gmdate( 'Y-m-d H:i:s', $now ); foreach ( $rows as $r ) { $scored = self::score_row( $r, $state ); if ( '' !== $r['segment'] && $r['segment'] !== $scored['segment'] ) { $changes[] = array( $r['customer_key'], $r['segment'], $scored['segment'] ); } $values[] = $wpdb->prepare( '(%s, %d, %d, %d, %s, %d, %f, %s)', $r['customer_key'], $scored['r'], $scored['f'], $scored['m'], $scored['segment'], $scored['risk'], $scored['value'], $stamp ); if ( count( $values ) >= 500 ) { self::write_scores( $values ); $values = array(); } } self::write_scores( $values ); Changes::record( $changes, $stamp ); /** * Fires after every customer has been scored. * * @param array $state Cut points, store gap and model summary. */ do_action( 'statbird_scored', $state ); return $state; } /** * Scores one stored row with the current cut points and model. * * @param array $r Row with recency, tenure, orders, spent, avg_gap_days. * @param array $state From state(). * @return array{r:int,f:int,m:int,segment:string,risk:int,value:float,probability:float} */ public static function score_row( array $r, array $state ) { $orders = (int) $r['orders']; list( $rs, $fs, $ms ) = self::rfm( $r['recency'], $orders, (float) $r['spent'], $state['cuts'] ); $gap = (float) $r['avg_gap_days']; $expected = $orders > 1 ? max( 1.0, $gap ) : (float) $state['store_gap']; if ( 'model' === $state['method'] && ! empty( $state['fit']['model'] ) ) { $p = Model::predict( $state['fit']['model'], Model::features( $r['recency'], $orders, (float) $r['spent'], $gap, $r['tenure'], $expected ) ); } else { $p = Model::rule_probability( $r['recency'], $expected ); } return array( 'r' => $rs, 'f' => $fs, 'm' => $ms, 'segment' => self::group_for( $rs, $fs ), 'risk' => (int) round( $p * 100 ), 'value' => self::value_12m( $orders, (float) $r['spent'], $r['tenure'], $p ), 'probability' => $p, ); } /** * Writes a batch of scores. * * @param string[] $values Prepared value tuples. */ private static function write_scores( array $values ) { global $wpdb; if ( ! $values ) { return; } $table = Store::table(); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- values prepared by the caller. $wpdb->query( "INSERT INTO $table (customer_key, r, f, m, segment, churn_risk, value_12m, updated_at) VALUES " . implode( ',', $values ) . ' ON DUPLICATE KEY UPDATE r = VALUES(r), f = VALUES(f), m = VALUES(m), segment = VALUES(segment), churn_risk = VALUES(churn_risk), value_12m = VALUES(value_12m)' ); // phpcs:enable } /** * Scores one customer after their orders changed, with the cut points from the last run. * * @param string $customer_key MD5 key. * @param array|null $row Fresh aggregate, or null when removed. */ public static function score_one( $customer_key, $row ) { global $wpdb; $state = self::state(); if ( ! $row || empty( $state['cuts'] ) ) { return; } $table = Store::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name only. $r = $wpdb->get_row( $wpdb->prepare( "SELECT customer_key, first_order, last_order, orders, spent, avg_gap_days, segment FROM $table WHERE customer_key = %s", $customer_key ), ARRAY_A ); if ( ! $r ) { return; } $now = time(); $r['recency'] = max( 0, ( $now - strtotime( $r['last_order'] . ' UTC' ) ) / DAY_IN_SECONDS ); $r['tenure'] = max( 0, ( $now - strtotime( $r['first_order'] . ' UTC' ) ) / DAY_IN_SECONDS ); $s = self::score_row( $r, $state ); $stamp = gmdate( 'Y-m-d H:i:s', $now ); self::write_scores( array( $wpdb->prepare( '(%s, %d, %d, %d, %s, %d, %f, %s)', $customer_key, $s['r'], $s['f'], $s['m'], $s['segment'], $s['risk'], $s['value'], $stamp ) ) ); if ( $r['segment'] !== $s['segment'] ) { Changes::record( array( array( $customer_key, $r['segment'], $s['segment'] ) ), $stamp ); } } }