fix(growth): harden deterministic report evidence
This commit is contained in:
@@ -20,7 +20,7 @@ func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horiz
|
||||
if len(ordered) < minimumHistoryDays {
|
||||
return result
|
||||
}
|
||||
result.Backtest = backtest(ordered)
|
||||
result.Backtest = backtest(ordered, catalog)
|
||||
result.Confidence = classifyConfidence(result.Backtest)
|
||||
if result.Confidence == ConfidenceInsufficientData {
|
||||
return result
|
||||
@@ -31,28 +31,11 @@ func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horiz
|
||||
}
|
||||
|
||||
func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, horizon int) []ForecastPoint {
|
||||
sessionResiduals, amountResiduals := residuals(history)
|
||||
trendSessions := trendBPS(history, func(p DailyPoint) int64 { return p.PaidSessionCount })
|
||||
trendAmount := trendBPS(history, func(p DailyPoint) int64 { return p.PaidAmountRial })
|
||||
familyEffects := eventFamilyEffects(history, catalog)
|
||||
sessionResiduals, amountResiduals := modelResiduals(history, catalog)
|
||||
points := make([]ForecastPoint, 0, horizon)
|
||||
for index := 0; index < horizon; index++ {
|
||||
day := start.AddDate(0, 0, index)
|
||||
baselineSessions := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount })
|
||||
baselineAmount := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial })
|
||||
predictedSessions := applyBPS(baselineSessions, trendSessions)
|
||||
predictedAmount := applyBPS(baselineAmount, trendAmount)
|
||||
eventIDs := make([]string, 0, 1)
|
||||
for _, event := range catalog {
|
||||
if day.Before(normalizeDate(event.StartDate)) || day.After(normalizeDate(event.EndDate)) {
|
||||
continue
|
||||
}
|
||||
eventIDs = append(eventIDs, event.ID)
|
||||
if uplift, ok := familyEffects[event.Family]; ok {
|
||||
predictedSessions = applyBPS(predictedSessions, uplift.sessions)
|
||||
predictedAmount = applyBPS(predictedAmount, uplift.amount)
|
||||
}
|
||||
}
|
||||
baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs := predictCenter(history, catalog, day)
|
||||
points = append(points, ForecastPoint{
|
||||
Date: day, BaselineSessions: baselineSessions, BaselineAmountRial: baselineAmount,
|
||||
PaidSessions: rangeFromResiduals(predictedSessions, sessionResiduals),
|
||||
@@ -65,7 +48,7 @@ func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, hori
|
||||
func normalizedHistory(history []DailyPoint, before time.Time) []DailyPoint {
|
||||
ordered := make([]DailyPoint, 0, len(history))
|
||||
for _, point := range history {
|
||||
if point.Date.Before(before) {
|
||||
if dateBefore(point.Date, before) {
|
||||
point.Date = normalizeDate(point.Date)
|
||||
ordered = append(ordered, point)
|
||||
}
|
||||
@@ -97,13 +80,14 @@ func median(values []int64) int64 {
|
||||
return (copyOfValues[middle-1] + copyOfValues[middle]) / 2
|
||||
}
|
||||
|
||||
func residuals(history []DailyPoint) ([]int64, []int64) {
|
||||
sessions := make([]int64, 0, len(history)-28)
|
||||
amounts := make([]int64, 0, len(history)-28)
|
||||
for index := 28; index < len(history); index++ {
|
||||
func modelResiduals(history []DailyPoint, catalog []Event) ([]int64, []int64) {
|
||||
sessions := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays))
|
||||
amounts := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays))
|
||||
for index := minimumHistoryDays; index < len(history); index++ {
|
||||
prior := history[:index]
|
||||
sessions = append(sessions, history[index].PaidSessionCount-weekdayMedian(prior, history[index].Date.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount }))
|
||||
amounts = append(amounts, history[index].PaidAmountRial-weekdayMedian(prior, history[index].Date.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial }))
|
||||
_, _, predictedSessions, predictedAmount, _ := predictCenter(prior, catalog, history[index].Date)
|
||||
sessions = append(sessions, history[index].PaidSessionCount-predictedSessions)
|
||||
amounts = append(amounts, history[index].PaidAmountRial-predictedAmount)
|
||||
}
|
||||
return sessions, amounts
|
||||
}
|
||||
@@ -147,22 +131,23 @@ func trendBPS(history []DailyPoint, value func(DailyPoint) int64) int64 {
|
||||
return trend
|
||||
}
|
||||
|
||||
func backtest(history []DailyPoint) BacktestResult {
|
||||
func backtest(history []DailyPoint, catalog []Event) BacktestResult {
|
||||
result := BacktestResult{}
|
||||
var sessionError, amountError, covered int64
|
||||
var evaluated int64
|
||||
first := len(history) - 28
|
||||
for origin := first; origin < len(history); origin += 7 {
|
||||
first := len(history) - 56
|
||||
for origin := first; origin < len(history); origin += 14 {
|
||||
if origin < minimumHistoryDays {
|
||||
continue
|
||||
}
|
||||
result.Origins++
|
||||
training := history[:origin]
|
||||
sessionResiduals, amountResiduals := residuals(training)
|
||||
for index := origin; index < minInt(origin+7, len(history)); index++ {
|
||||
sessionResiduals, amountResiduals := modelResiduals(training, catalog)
|
||||
for index := origin; index < minInt(origin+14, len(history)); index++ {
|
||||
actual := history[index]
|
||||
sessionRange := rangeFromResiduals(weekdayMedian(training, actual.Date.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount }), sessionResiduals)
|
||||
amountRange := rangeFromResiduals(weekdayMedian(training, actual.Date.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial }), amountResiduals)
|
||||
_, _, sessionCenter, amountCenter, _ := predictCenter(training, catalog, actual.Date)
|
||||
sessionRange := rangeFromResiduals(sessionCenter, sessionResiduals)
|
||||
amountRange := rangeFromResiduals(amountCenter, amountResiduals)
|
||||
sessionError += absolutePercentageErrorBPS(actual.PaidSessionCount, sessionRange.P50)
|
||||
amountError += absolutePercentageErrorBPS(actual.PaidAmountRial, amountRange.P50)
|
||||
if actual.PaidSessionCount >= sessionRange.P10 && actual.PaidSessionCount <= sessionRange.P90 && actual.PaidAmountRial >= amountRange.P10 && actual.PaidAmountRial <= amountRange.P90 {
|
||||
@@ -179,6 +164,26 @@ func backtest(history []DailyPoint) BacktestResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func predictCenter(history []DailyPoint, catalog []Event, day time.Time) (int64, int64, int64, int64, []string) {
|
||||
baselineSessions := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount })
|
||||
baselineAmount := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial })
|
||||
predictedSessions := applyBPS(baselineSessions, trendBPS(history, func(p DailyPoint) int64 { return p.PaidSessionCount }))
|
||||
predictedAmount := applyBPS(baselineAmount, trendBPS(history, func(p DailyPoint) int64 { return p.PaidAmountRial }))
|
||||
eventIDs := make([]string, 0, 1)
|
||||
familyEffects := eventFamilyEffects(history, catalog)
|
||||
for _, event := range catalog {
|
||||
if dateBefore(day, event.StartDate) || dateAfter(day, event.EndDate) {
|
||||
continue
|
||||
}
|
||||
eventIDs = append(eventIDs, event.ID)
|
||||
if uplift, ok := familyEffects[event.Family]; ok {
|
||||
predictedSessions = applyBPS(predictedSessions, uplift.sessions)
|
||||
predictedAmount = applyBPS(predictedAmount, uplift.amount)
|
||||
}
|
||||
}
|
||||
return baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs
|
||||
}
|
||||
|
||||
func classifyConfidence(backtest BacktestResult) Confidence {
|
||||
if backtest.Origins < 4 {
|
||||
return ConfidenceInsufficientData
|
||||
@@ -199,7 +204,7 @@ func eventFamilyEffects(history []DailyPoint, catalog []Event) map[string]family
|
||||
result := make(map[string]familyUplift)
|
||||
counts := make(map[string]int64)
|
||||
for _, event := range catalog {
|
||||
if event.Family == "" || event.StartDate.After(history[len(history)-1].Date) {
|
||||
if event.Family == "" || dateAfter(event.StartDate, history[len(history)-1].Date) {
|
||||
continue
|
||||
}
|
||||
effect := AnalyzeEventEffect(history, event, catalog)
|
||||
@@ -249,11 +254,18 @@ func minInt(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func DetectRisks(forecast Forecast, limit int) []Risk {
|
||||
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
risks := make([]Risk, 0, limit)
|
||||
candidates := make([]Risk, 0, len(forecast.Points))
|
||||
for _, point := range forecast.Points {
|
||||
if point.BaselineSessions < MinBaselineSessions {
|
||||
continue
|
||||
@@ -263,10 +275,49 @@ func DetectRisks(forecast Forecast, limit int) []Risk {
|
||||
if sessionDecline < MaterialChangeBPS && amountDecline < MaterialChangeBPS {
|
||||
continue
|
||||
}
|
||||
risks = append(risks, Risk{EvidenceID: "risk:" + dayKey(point.Date), Date: point.Date, SessionDeclineBPS: maxInt64(0, sessionDecline), AmountDeclineBPS: maxInt64(0, amountDecline), ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial})
|
||||
if len(risks) == limit {
|
||||
break
|
||||
candidates = append(candidates, Risk{Date: point.Date, EndDate: point.Date, SessionDeclineBPS: maxInt64(0, sessionDecline), AmountDeclineBPS: maxInt64(0, amountDecline), ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial})
|
||||
}
|
||||
coalesced := coalesceRisks(candidates)
|
||||
slices.SortStableFunc(coalesced, func(a, b Risk) int {
|
||||
aSeverity := maxInt64(a.SessionDeclineBPS, a.AmountDeclineBPS)
|
||||
bSeverity := maxInt64(b.SessionDeclineBPS, b.AmountDeclineBPS)
|
||||
if aSeverity > bSeverity {
|
||||
return -1
|
||||
}
|
||||
if aSeverity < bSeverity {
|
||||
return 1
|
||||
}
|
||||
return a.Date.Compare(b.Date)
|
||||
})
|
||||
if len(coalesced) > limit {
|
||||
coalesced = coalesced[:limit]
|
||||
}
|
||||
return coalesced
|
||||
}
|
||||
|
||||
func coalesceRisks(candidates []Risk) []Risk {
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]Risk, 0, len(candidates))
|
||||
current := candidates[0]
|
||||
for _, candidate := range candidates[1:] {
|
||||
if dayKey(candidate.Date) != dayKey(current.EndDate.AddDate(0, 0, 1)) {
|
||||
current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate))
|
||||
result = append(result, current)
|
||||
current = candidate
|
||||
continue
|
||||
}
|
||||
current.EndDate = candidate.EndDate
|
||||
if candidate.SessionDeclineBPS > current.SessionDeclineBPS {
|
||||
current.SessionDeclineBPS = candidate.SessionDeclineBPS
|
||||
current.ExpectedSessions = candidate.ExpectedSessions
|
||||
}
|
||||
if candidate.AmountDeclineBPS > current.AmountDeclineBPS {
|
||||
current.AmountDeclineBPS = candidate.AmountDeclineBPS
|
||||
current.ExpectedAmountRial = candidate.ExpectedAmountRial
|
||||
}
|
||||
}
|
||||
return risks
|
||||
current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate))
|
||||
return append(result, current)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user