162 lines
5.2 KiB
TypeScript
162 lines
5.2 KiB
TypeScript
import { PricingTier, RideSample, RegressionResult } from './types';
|
||
import {
|
||
twoStageRegression,
|
||
robustMultipleLinearRegression,
|
||
simpleDistanceModel,
|
||
calcRMSE,
|
||
calcRSquared,
|
||
detectMinimumFare,
|
||
} from '../utils/math';
|
||
|
||
type RawModel = { baseFare: number; kmRate: number; minRate: number };
|
||
|
||
/** Evaluate RMSE of a model against the actual samples. */
|
||
function evalRMSE(model: RawModel, samples: RideSample[]): number {
|
||
const actual = samples.map(s => s.price);
|
||
const predicted = samples.map(s =>
|
||
model.baseFare + model.kmRate * s.distance_km + model.minRate * s.duration_min
|
||
);
|
||
return calcRMSE(actual, predicted);
|
||
}
|
||
|
||
/**
|
||
* Select the best model from two candidates.
|
||
*
|
||
* Strategy:
|
||
* 1. Always run both two-stage and robust-MLR.
|
||
* 2. Primary criterion: lower RMSE wins.
|
||
* 3. Tie-break: if both models have similar RMSE (within 5%), prefer the one
|
||
* with a positive minRate — this respects the domain knowledge that time
|
||
* is always part of the taxi pricing formula.
|
||
*/
|
||
function selectBestModel(
|
||
modelA: RawModel | null, // two-stage
|
||
modelB: RawModel | null, // robust-MLR
|
||
samples: RideSample[]
|
||
): { model: RawModel; name: string } | null {
|
||
if (!modelA && !modelB) return null;
|
||
if (!modelA) return { model: modelB!, name: 'robust-MLR' };
|
||
if (!modelB) return { model: modelA, name: 'two-stage' };
|
||
|
||
const rmseA = evalRMSE(modelA, samples);
|
||
const rmseB = evalRMSE(modelB, samples);
|
||
|
||
// If RMSE difference is within 5%, prefer the model with a time component
|
||
const tolerance = Math.min(rmseA, rmseB) * 0.05;
|
||
if (Math.abs(rmseA - rmseB) <= tolerance) {
|
||
const aHasTime = modelA.minRate > 0.005;
|
||
const bHasTime = modelB.minRate > 0.005;
|
||
if (aHasTime && !bHasTime) return { model: modelA, name: 'two-stage' };
|
||
if (bHasTime && !aHasTime) return { model: modelB, name: 'robust-MLR' };
|
||
}
|
||
|
||
return rmseA <= rmseB
|
||
? { model: modelA, name: 'two-stage' }
|
||
: { model: modelB, name: 'robust-MLR' };
|
||
}
|
||
|
||
/**
|
||
* Run regression on a single pricing tier.
|
||
*
|
||
* Tries two approaches and picks the best:
|
||
* A) Two-stage regression — estimates flag fall first, then km + min rates
|
||
* B) Robust MLR — iterative outlier removal on full 3-parameter model
|
||
*
|
||
* The winner is chosen by RMSE, with a 5% tie-break that prefers models
|
||
* with a positive per-minute rate (time is always a component in real meters).
|
||
*/
|
||
export function analyzeTier(tier: PricingTier): PricingTier {
|
||
const samples = tier.samples;
|
||
if (samples.length < 5) {
|
||
tier.regression = null;
|
||
return tier;
|
||
}
|
||
|
||
const input: Array<{ distance_km: number; duration_min: number; price: number }> =
|
||
samples.map(s => ({
|
||
distance_km: s.distance_km,
|
||
duration_min: s.duration_min,
|
||
price: s.price,
|
||
}));
|
||
|
||
// Run all three models
|
||
const modelA = twoStageRegression(input); // Stage 1: flag fall | Stage 2: km + min
|
||
const modelB = robustMultipleLinearRegression(input); // Iterative outlier removal
|
||
const modelC = simpleDistanceModel(input); // distance-only: price = k × dist
|
||
|
||
const best = selectBestModel(modelA, modelB, samples);
|
||
|
||
// Distance-only fallback: if it's within 10% of the best model, prefer it
|
||
let finalModel = best;
|
||
if (finalModel && modelC) {
|
||
const rmseBest = evalRMSE(finalModel.model, samples);
|
||
const rmseDist = evalRMSE(modelC, samples);
|
||
if (rmseDist <= rmseBest * 1.10) {
|
||
finalModel = { model: modelC, name: 'distance-only' };
|
||
}
|
||
}
|
||
|
||
if (!finalModel) {
|
||
tier.regression = null;
|
||
return tier;
|
||
}
|
||
|
||
const { model: mlrResult, name: modelName } = finalModel;
|
||
|
||
// Compute final metrics using the winning model
|
||
const actualPrices = samples.map(s => s.price);
|
||
const predictedPrices = samples.map(s =>
|
||
mlrResult.baseFare + mlrResult.kmRate * s.distance_km + mlrResult.minRate * s.duration_min
|
||
);
|
||
|
||
const rmse = calcRMSE(actualPrices, predictedPrices);
|
||
const rSquared = calcRSquared(actualPrices, predictedPrices);
|
||
|
||
// Detect minimum fare (floor charge for very short trips)
|
||
const minFare = detectMinimumFare(
|
||
samples.map(s => s.distance_km),
|
||
samples.map(s => s.price),
|
||
mlrResult.kmRate
|
||
);
|
||
|
||
let hasMinFare = false;
|
||
let adjustedRMSE = rmse;
|
||
let adjustedRSquared = rSquared;
|
||
|
||
if (minFare && minFare > 0) {
|
||
const adjustedPredicted = samples.map(s => {
|
||
const raw = mlrResult.baseFare +
|
||
mlrResult.kmRate * s.distance_km +
|
||
mlrResult.minRate * s.duration_min;
|
||
return Math.max(raw, minFare);
|
||
});
|
||
const adjRmse = calcRMSE(actualPrices, adjustedPredicted);
|
||
const adjRsq = calcRSquared(actualPrices, adjustedPredicted);
|
||
|
||
if (adjRmse < rmse) {
|
||
hasMinFare = true;
|
||
adjustedRMSE = adjRmse;
|
||
adjustedRSquared = adjRsq;
|
||
}
|
||
}
|
||
|
||
tier.regression = {
|
||
baseFare: mlrResult.baseFare,
|
||
kmRate: mlrResult.kmRate,
|
||
minRate: mlrResult.minRate,
|
||
minFare: minFare || 0,
|
||
rmse: adjustedRMSE,
|
||
rSquared: adjustedRSquared,
|
||
sampleCount: samples.length,
|
||
hasMinFare,
|
||
modelName, // carry through for display
|
||
};
|
||
|
||
return tier;
|
||
}
|
||
|
||
/** Run regression on all tiers. */
|
||
export function analyzeAllTiers(tiers: PricingTier[]): PricingTier[] {
|
||
return tiers.map(tier => analyzeTier(tier));
|
||
}
|