7a18dd8360
- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed) con diálogo de propagación al editar: solo este / adelante / atrás / todos (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged) - 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos; ahora agrupa por mes calendario crudo - 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para recrear el StateObject al cambiar de selección - 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs arriba / detalle debajo - 145: card de contribución mensual en SourceDetailView - Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
282 lines
13 KiB
Swift
282 lines
13 KiB
Swift
import Foundation
|
|
|
|
class PredictionEngine {
|
|
static let shared = PredictionEngine()
|
|
|
|
private static let calendar = Calendar.current
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Public Interface
|
|
|
|
func predict(snapshots: [Snapshot], monthsAhead: Int = 12, algorithm: PredictionAlgorithm? = nil) -> PredictionResult {
|
|
guard snapshots.count >= 3 else { return emptyResult() }
|
|
let sorted = snapshots.sorted { $0.date < $1.date }
|
|
return predictFromValues(
|
|
sorted.map { $0.decimalValue.doubleValue },
|
|
dates: sorted.map { $0.date },
|
|
monthsAhead: monthsAhead,
|
|
algorithm: algorithm
|
|
)
|
|
}
|
|
|
|
func predict(series: [(date: Date, value: Decimal)], monthsAhead: Int = 12, algorithm: PredictionAlgorithm? = nil) -> PredictionResult {
|
|
guard series.count >= 3 else { return emptyResult() }
|
|
let sorted = series.sorted { $0.date < $1.date }
|
|
return predictFromValues(
|
|
sorted.map { NSDecimalNumber(decimal: $0.value).doubleValue },
|
|
dates: sorted.map { $0.date },
|
|
monthsAhead: monthsAhead,
|
|
algorithm: algorithm
|
|
)
|
|
}
|
|
|
|
// MARK: - Private Core
|
|
|
|
private func emptyResult() -> PredictionResult {
|
|
PredictionResult(predictions: [], algorithm: .linear, accuracy: 0, volatility: 0)
|
|
}
|
|
|
|
private func predictFromValues(_ values: [Double], dates: [Date], monthsAhead: Int, algorithm: PredictionAlgorithm?) -> PredictionResult {
|
|
let volatility = calculateVolatility(values: values)
|
|
let selectedAlgorithm = algorithm ?? selectBestAlgorithm(volatility: volatility)
|
|
let lastDate = dates.last!
|
|
let firstDate = dates.first!
|
|
|
|
let predictions: [Prediction]
|
|
let accuracy: Double
|
|
|
|
switch selectedAlgorithm {
|
|
case .linear:
|
|
predictions = linearPredictions(values: values, firstDate: firstDate, lastDate: lastDate, monthsAhead: monthsAhead)
|
|
accuracy = linearAccuracy(values: values, firstDate: firstDate)
|
|
case .exponentialSmoothing:
|
|
predictions = esPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead)
|
|
accuracy = esAccuracy(values: values)
|
|
case .movingAverage:
|
|
predictions = maPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead)
|
|
accuracy = maAccuracy(values: values)
|
|
case .holtTrend:
|
|
predictions = holtPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead)
|
|
accuracy = holtAccuracy(values: values)
|
|
}
|
|
|
|
return PredictionResult(predictions: predictions, algorithm: selectedAlgorithm, accuracy: accuracy, volatility: volatility)
|
|
}
|
|
|
|
// MARK: - Algorithm Selection
|
|
|
|
private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm {
|
|
switch volatility {
|
|
case 0..<8: return .holtTrend
|
|
case 8..<20: return .exponentialSmoothing
|
|
default: return .movingAverage
|
|
}
|
|
}
|
|
|
|
// MARK: - Linear Regression
|
|
|
|
private func linearPredictions(values: [Double], firstDate: Date, lastDate: Date, monthsAhead: Int) -> [Prediction] {
|
|
let dataPoints = values.enumerated().map { (x: Double($0.offset), y: $0.element) }
|
|
let (slope, intercept) = calculateLinearRegression(dataPoints: dataPoints)
|
|
let residualStdDev = calculateResidualStdDev(dataPoints: dataPoints, slope: slope, intercept: intercept)
|
|
let n = Double(values.count)
|
|
|
|
return (1...monthsAhead).compactMap { month in
|
|
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
|
let x = n - 1 + Double(month)
|
|
let predicted = max(0, slope * x + intercept)
|
|
let width = residualStdDev * 1.96 * (1.0 + Double(month) * 0.02)
|
|
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .linear,
|
|
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
|
}
|
|
}
|
|
|
|
private func linearAccuracy(values: [Double], firstDate: Date) -> Double {
|
|
guard values.count >= 5 else { return 0.5 }
|
|
let splitIndex = Int(Double(values.count) * 0.8)
|
|
let trainPoints = Array(values.prefix(splitIndex)).enumerated().map { (x: Double($0.offset), y: $0.element) }
|
|
let (slope, intercept) = calculateLinearRegression(dataPoints: trainPoints)
|
|
let validation = Array(values.suffix(from: splitIndex))
|
|
let mean = validation.reduce(0, +) / Double(validation.count)
|
|
var ssRes = 0.0, ssTot = 0.0
|
|
for (i, actual) in validation.enumerated() {
|
|
let x = Double(splitIndex + i)
|
|
ssRes += pow(actual - (slope * x + intercept), 2)
|
|
ssTot += pow(actual - mean, 2)
|
|
}
|
|
guard ssTot != 0 else { return 0.5 }
|
|
return min(1.0, max(0, 1 - ssRes / ssTot))
|
|
}
|
|
|
|
// MARK: - Exponential Smoothing
|
|
|
|
private func esPredictions(values: [Double], lastDate: Date, monthsAhead: Int, alpha: Double = 0.3) -> [Prediction] {
|
|
var smoothed = values[0]
|
|
for i in 1..<values.count { smoothed = alpha * values[i] + (1 - alpha) * smoothed }
|
|
|
|
let trend: Double = values.count >= 2
|
|
? (values.suffix(3).reduce(0, +) / Double(min(3, values.count)) -
|
|
values.prefix(3).reduce(0, +) / Double(min(3, values.count))) / Double(values.count)
|
|
: 0
|
|
let stdDev = calculateStdDev(values: values)
|
|
|
|
return (1...monthsAhead).compactMap { month in
|
|
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
|
let predicted = max(0, smoothed + trend * Double(month))
|
|
let width = stdDev * 1.96 * (1.0 + Double(month) * 0.05)
|
|
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .exponentialSmoothing,
|
|
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
|
}
|
|
}
|
|
|
|
private func esAccuracy(values: [Double]) -> Double {
|
|
guard values.count >= 5 else { return 0.5 }
|
|
let splitIndex = Int(Double(values.count) * 0.8)
|
|
var smoothed = values[0]
|
|
for i in 1..<splitIndex { smoothed = 0.3 * values[i] + 0.7 * smoothed }
|
|
let validation = Array(values.suffix(from: splitIndex))
|
|
let mean = validation.reduce(0, +) / Double(validation.count)
|
|
var ssRes = 0.0, ssTot = 0.0
|
|
for (i, actual) in validation.enumerated() {
|
|
let predicted = smoothed + (smoothed - values[splitIndex - 1]) * Double(i + 1) / Double(splitIndex)
|
|
ssRes += pow(actual - predicted, 2)
|
|
ssTot += pow(actual - mean, 2)
|
|
}
|
|
guard ssTot != 0 else { return 0.5 }
|
|
return max(0, min(1.0, 1 - ssRes / ssTot))
|
|
}
|
|
|
|
// MARK: - Moving Average
|
|
|
|
private func maPredictions(values: [Double], lastDate: Date, monthsAhead: Int, windowSize: Int = 3) -> [Prediction] {
|
|
guard values.count >= windowSize else { return [] }
|
|
let recent = Array(values.suffix(windowSize))
|
|
let movingAvg = recent.reduce(0, +) / Double(windowSize)
|
|
var changes: [Double] = []
|
|
for i in 1..<values.count { changes.append(values[i] - values[i - 1]) }
|
|
let avgChange = changes.isEmpty ? 0 : changes.reduce(0, +) / Double(changes.count)
|
|
let stdDev = calculateStdDev(values: values)
|
|
|
|
return (1...monthsAhead).compactMap { month in
|
|
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
|
let predicted = max(0, movingAvg + avgChange * Double(month))
|
|
let width = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
|
|
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .movingAverage,
|
|
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
|
}
|
|
}
|
|
|
|
private func maAccuracy(values: [Double], windowSize: Int = 3) -> Double {
|
|
guard values.count >= 5 else { return 0.5 }
|
|
let splitIndex = Int(Double(values.count) * 0.8)
|
|
guard splitIndex > windowSize else { return 0.5 }
|
|
let recentWindow = Array(values[(splitIndex - windowSize)..<splitIndex])
|
|
let movingAvg = recentWindow.reduce(0, +) / Double(windowSize)
|
|
let validation = Array(values.suffix(from: splitIndex))
|
|
let mean = validation.reduce(0, +) / Double(validation.count)
|
|
var ssRes = 0.0, ssTot = 0.0
|
|
for actual in validation {
|
|
ssRes += pow(actual - movingAvg, 2)
|
|
ssTot += pow(actual - mean, 2)
|
|
}
|
|
guard ssTot != 0 else { return 0.5 }
|
|
return max(0, min(1.0, 1 - ssRes / ssTot))
|
|
}
|
|
|
|
// MARK: - Holt Trend (Double Exponential Smoothing)
|
|
|
|
private func holtPredictions(values: [Double], lastDate: Date, monthsAhead: Int, alpha: Double = 0.4, beta: Double = 0.3) -> [Prediction] {
|
|
var level = values[0]
|
|
var trend = values[1] - values[0]
|
|
var fitted: [Double] = []
|
|
for value in values {
|
|
let lastLevel = level
|
|
level = alpha * value + (1 - alpha) * (level + trend)
|
|
trend = beta * (level - lastLevel) + (1 - beta) * trend
|
|
fitted.append(level + trend)
|
|
}
|
|
let stdDev = calculateStdDev(values: zip(values, fitted).map { $0 - $1 })
|
|
|
|
return (1...monthsAhead).compactMap { month in
|
|
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
|
let predicted = max(0, level + Double(month) * trend)
|
|
let width = stdDev * 1.96 * (1.0 + Double(month) * 0.04)
|
|
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .holtTrend,
|
|
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
|
}
|
|
}
|
|
|
|
private func holtAccuracy(values: [Double]) -> Double {
|
|
guard values.count >= 5 else { return 0.5 }
|
|
let splitIndex = Int(Double(values.count) * 0.8)
|
|
guard splitIndex >= 2 else { return 0.5 }
|
|
var level = values[0]
|
|
var trend = values[1] - values[0]
|
|
for value in values.prefix(splitIndex) {
|
|
let lastLevel = level
|
|
level = 0.4 * value + 0.6 * (level + trend)
|
|
trend = 0.3 * (level - lastLevel) + 0.7 * trend
|
|
}
|
|
let validation = Array(values.suffix(from: splitIndex))
|
|
let mean = validation.reduce(0, +) / Double(validation.count)
|
|
var ssRes = 0.0, ssTot = 0.0
|
|
for (i, actual) in validation.enumerated() {
|
|
ssRes += pow(actual - (level + Double(i + 1) * trend), 2)
|
|
ssTot += pow(actual - mean, 2)
|
|
}
|
|
guard ssTot != 0 else { return 0.5 }
|
|
return max(0, min(1.0, 1 - ssRes / ssTot))
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func calculateVolatility(values: [Double]) -> Double {
|
|
guard values.count >= 2 else { return 0 }
|
|
var returns: [Double] = []
|
|
for i in 1..<values.count {
|
|
guard values[i - 1] != 0 else { continue }
|
|
returns.append((values[i] - values[i - 1]) / values[i - 1] * 100)
|
|
}
|
|
return calculateStdDev(values: returns)
|
|
}
|
|
|
|
private func calculateLinearRegression(dataPoints: [(x: Double, y: Double)]) -> (slope: Double, intercept: Double) {
|
|
let n = Double(dataPoints.count)
|
|
let sumX = dataPoints.reduce(0) { $0 + $1.x }
|
|
let sumY = dataPoints.reduce(0) { $0 + $1.y }
|
|
let sumXY = dataPoints.reduce(0) { $0 + $1.x * $1.y }
|
|
let sumX2 = dataPoints.reduce(0) { $0 + $1.x * $1.x }
|
|
let denom = n * sumX2 - sumX * sumX
|
|
guard denom != 0 else { return (0, sumY / n) }
|
|
let slope = (n * sumXY - sumX * sumY) / denom
|
|
return (slope, (sumY - slope * sumX) / n)
|
|
}
|
|
|
|
private func calculateResidualStdDev(dataPoints: [(x: Double, y: Double)], slope: Double, intercept: Double) -> Double {
|
|
guard dataPoints.count > 2 else { return 0 }
|
|
let mse = dataPoints.map { pow($0.y - (slope * $0.x + intercept), 2) }.reduce(0, +) / Double(dataPoints.count - 2)
|
|
return sqrt(mse)
|
|
}
|
|
|
|
private func calculateStdDev(values: [Double]) -> Double {
|
|
guard values.count >= 2 else { return 0 }
|
|
let mean = values.reduce(0, +) / Double(values.count)
|
|
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count - 1)
|
|
return sqrt(variance)
|
|
}
|
|
|
|
// MARK: - Public compatibility (kept for external callers)
|
|
|
|
func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] {
|
|
guard snapshots.count >= 3 else { return [] }
|
|
let sorted = snapshots.sorted { $0.date < $1.date }
|
|
return linearPredictions(
|
|
values: sorted.map { $0.decimalValue.doubleValue },
|
|
firstDate: sorted.first!.date,
|
|
lastDate: sorted.last!.date,
|
|
monthsAhead: monthsAhead
|
|
)
|
|
}
|
|
}
|