Update: 2026-07-06 18:23:46
This commit is contained in:
@@ -12,26 +12,35 @@ export interface EngineOptions {
|
||||
cleanOutliers?: boolean;
|
||||
/**
|
||||
* Surge threshold as a fraction of the median price (e.g. 0.05 = 5%).
|
||||
* Default: 0.05 (5% of median ride price).
|
||||
* The absolute threshold is computed dynamically per dataset so it scales
|
||||
* correctly across currencies (JOD, SYP, IQD, etc.).
|
||||
* Computed dynamically per dataset so it scales across currencies.
|
||||
*/
|
||||
surgeThresholdFraction?: number;
|
||||
tierCount?: number;
|
||||
/**
|
||||
* Known receipts to validate against — used to sanity-check the formula.
|
||||
* Each entry is a real fare that the engine's formula should be able to predict.
|
||||
*/
|
||||
knownReceipts?: Array<{
|
||||
label: string;
|
||||
distanceKm: number;
|
||||
durationMin: number;
|
||||
actualPrice: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main pricing analysis engine.
|
||||
* Orchestrates the full pipeline: fetch → clean → cluster → regress → surge → zone.
|
||||
* Pipeline: fetch → clean → cluster → regress → surge → zone → validate.
|
||||
*/
|
||||
export async function runAnalysis(
|
||||
samples: RideSample[],
|
||||
options: EngineOptions = {}
|
||||
): Promise<AnalysisReport> {
|
||||
const {
|
||||
cleanOutliers = true,
|
||||
cleanOutliers = true,
|
||||
surgeThresholdFraction = 0.05,
|
||||
tierCount = 3,
|
||||
tierCount = 3,
|
||||
knownReceipts = [],
|
||||
} = options;
|
||||
|
||||
if (samples.length < 5) {
|
||||
@@ -43,10 +52,9 @@ export async function runAnalysis(
|
||||
// Step 1: Remove statistical outliers (MAD on PPK)
|
||||
const cleanSamples = cleanOutliers ? removeOutliers(samples) : samples;
|
||||
|
||||
// Step 2: Compute dynamic surge threshold — 5% of median price, clamped to [0.05, 2.0]
|
||||
// This ensures the threshold scales correctly for high-denomination currencies.
|
||||
const allPrices = cleanSamples.map(s => s.price);
|
||||
const medianPrice = median(allPrices);
|
||||
// Step 2: Compute dynamic surge threshold (5% of median price)
|
||||
const allPrices = cleanSamples.map(s => s.price);
|
||||
const medianPrice = median(allPrices);
|
||||
const surgeThreshold = Math.min(Math.max(medianPrice * surgeThresholdFraction, 0.05), 2.0);
|
||||
|
||||
// Step 3: Group by route and extract base (non-surge) prices
|
||||
@@ -56,42 +64,54 @@ export async function runAnalysis(
|
||||
// Step 4: Cluster into pricing tiers by PPK
|
||||
const rawTiers = clusterTiers(cleanSamples, tierCount);
|
||||
|
||||
// Step 5: Run regression on each tier
|
||||
// Step 5: Run regression on each tier (two-stage + robust-MLR, best wins)
|
||||
const analyzedTiers = analyzeAllTiers(rawTiers);
|
||||
|
||||
// Step 6: Detect surge patterns
|
||||
const surgePatterns = detectSurge(cleanSamples, surgeThreshold);
|
||||
const surgeHours = aggregateSurgeHours(surgePatterns);
|
||||
const surgeHours = aggregateSurgeHours(surgePatterns);
|
||||
|
||||
// Step 7: Zone analysis
|
||||
const zones = analyzeByZone(cleanSamples);
|
||||
const zones = analyzeByZone(cleanSamples);
|
||||
const zoneTypes = analyzeByZoneType(cleanSamples);
|
||||
|
||||
// Build report
|
||||
const report: AnalysisReport = {
|
||||
competitorName: firstSample.competitorName,
|
||||
countryCode: firstSample.countryCode,
|
||||
tiers: analyzedTiers,
|
||||
countryCode: firstSample.countryCode,
|
||||
tiers: analyzedTiers,
|
||||
surgePatterns,
|
||||
zones,
|
||||
totalSamples: samples.length,
|
||||
analyzedAt: new Date().toISOString(),
|
||||
totalSamples: samples.length,
|
||||
analyzedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Print summary
|
||||
printSummary(report, baseSamples, surgeHours, zoneTypes, surgeThreshold);
|
||||
// Print full summary including receipt validation
|
||||
printSummary(report, baseSamples, surgeHours, zoneTypes, surgeThreshold, knownReceipts);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Summary printing
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function currencyCode(countryCode: string): string {
|
||||
const map: Record<string, string> = { JO: 'JOD', SY: 'SYP', IQ: 'IQD', SA: 'SAR', AE: 'AED', EG: 'EGP' };
|
||||
return map[countryCode] ?? 'CUR';
|
||||
}
|
||||
|
||||
function printSummary(
|
||||
report: AnalysisReport,
|
||||
baseSamples: RideSample[],
|
||||
surgeHours: ReturnType<typeof aggregateSurgeHours>,
|
||||
zoneTypes: ReturnType<typeof analyzeByZoneType>,
|
||||
surgeThreshold: number
|
||||
report: AnalysisReport,
|
||||
baseSamples: RideSample[],
|
||||
surgeHours: ReturnType<typeof aggregateSurgeHours>,
|
||||
zoneTypes: ReturnType<typeof analyzeByZoneType>,
|
||||
surgeThreshold: number,
|
||||
knownReceipts: NonNullable<EngineOptions['knownReceipts']>
|
||||
): void {
|
||||
const sep = '═══════════════════════════════════════════════════════';
|
||||
const cur = currencyCode(report.countryCode);
|
||||
|
||||
console.log(`\n${sep}`);
|
||||
console.log(` 📊 Pricing Analysis Report — ${report.competitorName} (${report.countryCode})`);
|
||||
console.log(` ${report.totalSamples} total samples, ${baseSamples.length} base-price samples`);
|
||||
@@ -99,28 +119,32 @@ function printSummary(
|
||||
console.log(` Analyzed at: ${report.analyzedAt}`);
|
||||
console.log(sep);
|
||||
|
||||
// Tiers
|
||||
// ── Tiers ───────────────────────────────────
|
||||
console.log(`\n📦 PRICING TIERS:`);
|
||||
for (const tier of report.tiers) {
|
||||
const reg = tier.regression;
|
||||
if (reg) {
|
||||
const tierIcon = tier.label === 'economy' ? '💰' : tier.label === 'standard' ? '🚗' : '💎';
|
||||
console.log(` ${tierIcon} ${tier.label.toUpperCase()}:`);
|
||||
console.log(` Base Fare: ${reg.baseFare.toFixed(3)} ${report.countryCode === 'JO' ? 'JOD' : report.countryCode === 'SY' ? 'SYP' : report.countryCode === 'IQ' ? 'IQD' : 'CUR'}`);
|
||||
const icon = tier.label === 'economy' ? '💰' : tier.label === 'standard' ? '🚗' : '💎';
|
||||
console.log(` ${icon} ${tier.label.toUpperCase()}: [model: ${reg.modelName}]`);
|
||||
console.log(` Flag Fall: ${reg.baseFare.toFixed(3)} ${cur}`);
|
||||
console.log(` Per KM: ${reg.kmRate.toFixed(3)}`);
|
||||
console.log(` Per Min: ${reg.minRate.toFixed(3)}`);
|
||||
console.log(` Per Min: ${reg.minRate.toFixed(3)}${reg.minRate < 0.005 ? ' ⚠️ (near-zero — may need more data)' : ''}`);
|
||||
console.log(` Min Fare: ${reg.minFare.toFixed(3)} ${reg.hasMinFare ? '✅ active' : ''}`);
|
||||
console.log(` RMSE: ${reg.rmse.toFixed(4)}`);
|
||||
console.log(` R²: ${reg.rSquared.toFixed(4)}`);
|
||||
console.log(` Samples: ${reg.sampleCount}`);
|
||||
console.log(` PPK range: ${tier.ppkRange[0].toFixed(3)} – ${tier.ppkRange[1].toFixed(3)}`);
|
||||
|
||||
// Formula preview
|
||||
const formula = buildFormulaString(reg.baseFare, reg.kmRate, reg.minRate, reg.minFare, cur);
|
||||
console.log(` Formula: ${formula}`);
|
||||
} else {
|
||||
console.log(` 📄 ${tier.label.toUpperCase()}: ${tier.samples.length} samples (insufficient for regression)`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Surge
|
||||
// ── Surge ───────────────────────────────────
|
||||
if (surgeHours.length > 0) {
|
||||
console.log(`⚡ SURGE PATTERNS (by hour-of-day):`);
|
||||
for (const sh of surgeHours) {
|
||||
@@ -130,7 +154,7 @@ function printSummary(
|
||||
console.log(`\nℹ️ No significant surge patterns detected.`);
|
||||
}
|
||||
|
||||
// Zones
|
||||
// ── Zones ───────────────────────────────────
|
||||
if (zoneTypes.length > 0) {
|
||||
console.log(`\n📍 ZONE TYPE ANALYSIS:`);
|
||||
for (const zt of zoneTypes) {
|
||||
@@ -138,14 +162,69 @@ function printSummary(
|
||||
}
|
||||
}
|
||||
|
||||
// Surge route details
|
||||
// ── Top Surge Routes ────────────────────────
|
||||
if (report.surgePatterns.length > 0) {
|
||||
console.log(`\n🔍 TOP SURGE ROUTES:`);
|
||||
for (const sr of report.surgePatterns.slice(0, 5)) {
|
||||
console.log(` ${sr.distanceKm.toFixed(1)}km → base ${sr.basePrice.toFixed(2)}, peak ${(sr.basePrice * sr.maxMultiplier).toFixed(2)} JOD (${sr.maxMultiplier.toFixed(3)}x)`);
|
||||
console.log(` ${sr.distanceKm.toFixed(1)}km → base ${sr.basePrice.toFixed(2)}, peak ${(sr.basePrice * sr.maxMultiplier).toFixed(2)} ${cur} (${sr.maxMultiplier.toFixed(3)}x)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receipt Validation ──────────────────────
|
||||
if (knownReceipts.length > 0) {
|
||||
console.log(`\n🧾 RECEIPT VALIDATION:`);
|
||||
|
||||
for (const receipt of knownReceipts) {
|
||||
console.log(`\n 📄 ${receipt.label}`);
|
||||
console.log(` Route: ${receipt.distanceKm} km / ${receipt.durationMin.toFixed(2)} min`);
|
||||
console.log(` Actual price: ${receipt.actualPrice.toFixed(3)} ${cur}`);
|
||||
|
||||
for (const tier of report.tiers) {
|
||||
const reg = tier.regression;
|
||||
if (!reg) continue;
|
||||
|
||||
const predicted =
|
||||
reg.baseFare +
|
||||
reg.kmRate * receipt.distanceKm +
|
||||
reg.minRate * receipt.durationMin;
|
||||
|
||||
const effective = reg.hasMinFare && reg.minFare > 0
|
||||
? Math.max(predicted, reg.minFare)
|
||||
: predicted;
|
||||
|
||||
const error = effective - receipt.actualPrice;
|
||||
const errorPct = (error / receipt.actualPrice) * 100;
|
||||
const sign = error >= 0 ? '+' : '';
|
||||
const flag = Math.abs(errorPct) <= 5 ? '✅' : Math.abs(errorPct) <= 15 ? '⚠️' : '❌';
|
||||
|
||||
console.log(` [${tier.label.padEnd(8)}] predicted: ${effective.toFixed(3)} ${cur} error: ${sign}${errorPct.toFixed(1)}% ${flag}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(sep);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable formula string for display.
|
||||
* e.g. "price = 0.440 + 0.220 × km + 0.040 × min (min fare: 0.800)"
|
||||
*/
|
||||
function buildFormulaString(
|
||||
baseFare: number,
|
||||
kmRate: number,
|
||||
minRate: number,
|
||||
minFare: number,
|
||||
cur: string
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (baseFare > 0.001) parts.push(`${baseFare.toFixed(3)}`);
|
||||
parts.push(`${kmRate.toFixed(3)} × km`);
|
||||
if (minRate > 0.001) parts.push(`${minRate.toFixed(3)} × min`);
|
||||
|
||||
let formula = `price = ${parts.join(' + ')}`;
|
||||
if (minFare > 0.001) formula += ` (min fare: ${minFare.toFixed(3)} ${cur})`;
|
||||
return formula;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,68 @@
|
||||
import { PricingTier, RegressionResult, RideSample } from './types';
|
||||
import { PricingTier, RideSample, RegressionResult } from './types';
|
||||
import {
|
||||
twoStageRegression,
|
||||
robustMultipleLinearRegression,
|
||||
calcRMSE,
|
||||
calcRSquared,
|
||||
detectMinimumFare,
|
||||
} from '../utils/math';
|
||||
import { mean } from 'simple-statistics';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run multiple linear regression on each pricing tier.
|
||||
* Detects minimum fare and computes RMSE/R².
|
||||
* 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;
|
||||
@@ -18,78 +71,78 @@ export function analyzeTier(tier: PricingTier): PricingTier {
|
||||
return tier;
|
||||
}
|
||||
|
||||
// Primary model: price = baseFare + kmRate * dist + minRate * dur
|
||||
// We use robust regression to strip out surge outliers and find the floor price
|
||||
const mlrResult = robustMultipleLinearRegression(
|
||||
const input: Array<{ distance_km: number; duration_min: number; price: number }> =
|
||||
samples.map(s => ({
|
||||
distance_km: s.distance_km,
|
||||
distance_km: s.distance_km,
|
||||
duration_min: s.duration_min,
|
||||
price: s.price,
|
||||
}))
|
||||
);
|
||||
price: s.price,
|
||||
}));
|
||||
|
||||
if (!mlrResult) {
|
||||
// Run both models
|
||||
const modelA = twoStageRegression(input); // Stage 1: flag fall | Stage 2: km + min
|
||||
const modelB = robustMultipleLinearRegression(input); // Current robust approach
|
||||
|
||||
const best = selectBestModel(modelA, modelB, samples);
|
||||
if (!best) {
|
||||
tier.regression = null;
|
||||
return tier;
|
||||
}
|
||||
|
||||
// Predict and compute RMSE/R²
|
||||
const actualPrices = samples.map(s => s.price);
|
||||
const { model: mlrResult, name: modelName } = best;
|
||||
|
||||
// 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
|
||||
mlrResult.baseFare + mlrResult.kmRate * s.distance_km + mlrResult.minRate * s.duration_min
|
||||
);
|
||||
|
||||
const rmse = calcRMSE(actualPrices, predictedPrices);
|
||||
const rmse = calcRMSE(actualPrices, predictedPrices);
|
||||
const rSquared = calcRSquared(actualPrices, predictedPrices);
|
||||
|
||||
// Detect minimum fare
|
||||
// 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
|
||||
);
|
||||
|
||||
// If minFare is detected and the short-ride residuals improve,
|
||||
// apply minFare-adjusted model
|
||||
let hasMinFare = false;
|
||||
let adjustedRMSE = rmse;
|
||||
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;
|
||||
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);
|
||||
const adjRsq = calcRSquared(actualPrices, adjustedPredicted);
|
||||
|
||||
// If minimum fare improves the fit, use it
|
||||
if (adjRmse < rmse) {
|
||||
hasMinFare = true;
|
||||
adjustedRMSE = adjRmse;
|
||||
hasMinFare = true;
|
||||
adjustedRMSE = adjRmse;
|
||||
adjustedRSquared = adjRsq;
|
||||
}
|
||||
}
|
||||
|
||||
tier.regression = {
|
||||
baseFare: mlrResult.baseFare,
|
||||
kmRate: mlrResult.kmRate,
|
||||
minRate: mlrResult.minRate,
|
||||
minFare: minFare || 0,
|
||||
rmse: adjustedRMSE,
|
||||
rSquared: adjustedRSquared,
|
||||
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.
|
||||
*/
|
||||
/** Run regression on all tiers. */
|
||||
export function analyzeAllTiers(tiers: PricingTier[]): PricingTier[] {
|
||||
return tiers.map(tier => analyzeTier(tier));
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface RegressionResult {
|
||||
rSquared: number;
|
||||
sampleCount: number;
|
||||
hasMinFare: boolean;
|
||||
/** Which regression model was selected: 'two-stage' | 'robust-MLR' */
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
export interface SurgeResult {
|
||||
|
||||
Reference in New Issue
Block a user