LotteryTest / model_compare.js
DennisChan0909's picture
initial deploy: 今彩539 AI 預測系統
2c7a564
Raw
History Blame Contribute Delete
10 kB
/**
* 今彩539 多模型比較回測 (Model Comparison Backtest)
*
* 同時測試 7 種不同預測策略,找出「至少命中 1 個」最高的模型。
* 測試範圍:最後 1000 期
*/
const fs = require('fs');
const raw = JSON.parse(fs.readFileSync('lottery_539_data.json', 'utf8'));
const draws = raw.draws;
const TOTAL = draws.length;
const BACKTEST_N = 1000;
const START_IDX = TOTAL - BACKTEST_N;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function createFreq() {
const f = {};
for (let i = 1; i <= 39; i++) f[pad(i)] = 0;
return f;
}
function pad(n) { return String(parseInt(n)).padStart(2, '0'); }
function addToFreq(freq, draw) {
for (const n of draw.numbers) freq[pad(n)]++;
}
// Gap: how many draws since last seen (per number)
function getGaps(draws, upToIdx) {
const gaps = {};
for (let i = 1; i <= 39; i++) gaps[pad(i)] = upToIdx; // default = never seen
for (let i = upToIdx - 1; i >= Math.max(0, upToIdx - 500); i--) {
for (const n of draws[i].numbers) {
const k = pad(n);
if (gaps[k] === upToIdx) gaps[k] = upToIdx - 1 - i;
}
}
return gaps;
}
// Pair co-occurrence: which pairs show up together most
function getPairFreq(draws, upToIdx) {
const pairs = {};
const window = Math.min(upToIdx, 500);
for (let i = upToIdx - window; i < upToIdx; i++) {
const nums = draws[i].numbers.map(pad);
for (let a = 0; a < nums.length; a++) {
for (let b = a + 1; b < nums.length; b++) {
const key = nums[a] + '-' + nums[b];
pairs[key] = (pairs[key] || 0) + 1;
}
}
}
return pairs;
}
// Score each number based on pair affinity to seed numbers
function pairScore(num, seeds, pairFreq) {
let score = 0;
for (const s of seeds) {
const a = [pad(num), s].sort().join('-');
score += pairFreq[a] || 0;
}
return score;
}
// Recent N-draw frequency
function recentFreq(draws, upToIdx, n = 30) {
const f = createFreq();
for (let i = Math.max(0, upToIdx - n); i < upToIdx; i++) {
for (const n of draws[i].numbers) f[pad(n)]++;
}
return f;
}
// ─── Model Definitions ────────────────────────────────────────────────────────
// Compute scores and pick top 5
function topN(scores, n = 5) {
return Object.entries(scores)
.sort((a, b) => b[1] - a[1])
.slice(0, n)
.map(([k]) => k)
.sort((a, b) => parseInt(a) - parseInt(b));
}
// SPREAD: pick highest-scored number from each of 5 buckets
// Bucket ranges: [1-8], [9-15], [16-22], [23-30], [31-39]
const BUCKETS = [
[1, 2, 3, 4, 5, 6, 7, 8],
[9, 10, 11, 12, 13, 14, 15],
[16, 17, 18, 19, 20, 21, 22],
[23, 24, 25, 26, 27, 28, 29, 30],
[31, 32, 33, 34, 35, 36, 37, 38, 39],
];
function spreadPick(scores) {
return BUCKETS.map(bucket => {
return bucket
.map(n => [pad(n), scores[pad(n)] || 0])
.sort((a, b) => b[1] - a[1])[0][0];
}).sort((a, b) => parseInt(a) - parseInt(b));
}
// All models: fn(freq, gaps, draws, upToIdx) => predicted [5 numbers]
const MODELS = {
'M1_Current': (freq, gaps, draws, idx) => {
// Original: 40% hot + 30% overdue + 20% cold + 10% recent
const total = idx * 5 || 1;
const maxFreq = Math.max(...Object.values(freq)) || 1;
const maxGap = Math.max(...Object.values(gaps)) || 1;
const rf = recentFreq(draws, idx, 30);
const rTotal = Math.min(idx, 30) * 5 || 1;
const s = {};
for (let i = 1; i <= 39; i++) {
const k = pad(i);
s[k] = (freq[k] / total) * 40
+ (gaps[k] / maxGap) * 30
+ ((maxFreq - freq[k]) / maxFreq) * 20
+ (rf[k] / rTotal) * 10;
}
return topN(s);
},
'M2_PureOverdue': (freq, gaps) => {
// Pure gap — pick numbers absent longest
return topN(gaps);
},
'M3_SpreadOverdue': (freq, gaps) => {
// Spread across 5 buckets, pick most overdue in each
return spreadPick(gaps);
},
'M4_SpreadCold': (freq, gaps, draws, idx) => {
// Spread across 5 buckets, pick least frequent in each
const s = {};
const maxF = Math.max(...Object.values(freq)) || 1;
for (let i = 1; i <= 39; i++) {
const k = pad(i);
s[k] = maxF - freq[k]; // invert: cold = high score
}
return spreadPick(s);
},
'M5_SpreadHybrid': (freq, gaps, draws, idx) => {
// SPREAD + weighted hybrid (overdue 50% + cold 30% + recent 20%)
// Pick best from each bucket
const total = idx * 5 || 1;
const maxF = Math.max(...Object.values(freq)) || 1;
const maxG = Math.max(...Object.values(gaps)) || 1;
const rf = recentFreq(draws, idx, 50);
const rTotal = Math.min(idx, 50) * 5 || 1;
const s = {};
for (let i = 1; i <= 39; i++) {
const k = pad(i);
s[k] = (gaps[k] / maxG) * 50
+ ((maxF - freq[k]) / maxF) * 30
+ (rf[k] / rTotal) * 20;
}
return spreadPick(s);
},
'M6_RecentAntiTrend': (freq, gaps, draws, idx) => {
// Anti-recent: avoid numbers that appeared in last 10 draws, favor overdue
const rShort = recentFreq(draws, idx, 10);
const maxG = Math.max(...Object.values(gaps)) || 1;
const s = {};
for (let i = 1; i <= 39; i++) {
const k = pad(i);
// penalize recently drawn, reward absence
s[k] = (gaps[k] / maxG) * 70 + (rShort[k] === 0 ? 30 : 0);
}
return spreadPick(s);
},
'M7_TopSpread': (freq, gaps, draws, idx) => {
// Score all numbers with hybrid, then enforce spread constraint
const total = idx * 5 || 1;
const maxF = Math.max(...Object.values(freq)) || 1;
const maxG = Math.max(...Object.values(gaps)) || 1;
const rf50 = recentFreq(draws, idx, 50);
const rTotal50 = Math.min(idx, 50) * 5 || 1;
const rf15 = recentFreq(draws, idx, 15);
const rTotal15 = Math.min(idx, 15) * 5 || 1;
const s = {};
for (let i = 1; i <= 39; i++) {
const k = pad(i);
s[k] = (gaps[k] / maxG) * 40 // overdue
+ ((maxF - freq[k]) / maxF) * 25 // cold
+ (freq[k] / total) * 10 // some hot
+ (rf50[k] / rTotal50) * 15 // mid-term trend
+ (rf15[k] === 0 ? 10 : 0); // bonus if not seen in 15 draws
}
// First pick top-1 from each bucket (spread), then fill remaining from overall top
const used = new Set();
const picks = [];
// One per bucket
for (const bucket of BUCKETS) {
const best = bucket
.map(n => [pad(n), s[pad(n)] || 0])
.sort((a, b) => b[1] - a[1])[0][0];
picks.push(best);
used.add(best);
}
return picks.sort((a, b) => parseInt(a) - parseInt(b));
},
};
// ─── Run Comparison ───────────────────────────────────────────────────────────
console.log(`🔬 Multi-Model Comparison Backtest`);
console.log(` Testing ${Object.keys(MODELS).length} models on draws ${START_IDX + 1}~${TOTAL}\n`);
// Pre-build initial freq + gaps
const initFreq = createFreq();
for (let i = 0; i < START_IDX; i++) addToFreq(initFreq, draws[i]);
// For each model, track hit counters
const modelFreqs = {};
const modelHits = {};
const modelCounts = {};
for (const name of Object.keys(MODELS)) {
modelFreqs[name] = { ...initFreq };
modelHits[name] = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
modelCounts[name] = 0;
}
const startTime = Date.now();
for (let i = START_IDX; i < TOTAL; i++) {
const actual = draws[i].numbers.map(pad);
const actualSet = new Set(actual);
const idx = i; // upToIdx (exclusive)
// Compute shared expensive stats once per period
const gaps = getGaps(draws, idx);
for (const [name, modelFn] of Object.entries(MODELS)) {
const freq = modelFreqs[name];
const predicted = modelFn(freq, gaps, draws, idx);
const hits = predicted.filter(p => actualSet.has(p)).length;
modelHits[name][hits]++;
modelCounts[name]++;
addToFreq(freq, draws[i]);
}
const done = i - START_IDX + 1;
if (done % 100 === 0 || done === BACKTEST_N) {
process.stdout.write(`\r Progress: ${done}/${BACKTEST_N} (${((done/BACKTEST_N)*100).toFixed(0)}%) — ${((Date.now()-startTime)/1000).toFixed(1)}s`);
}
}
console.log('\n\n📊 Results:\n');
console.log('Model'.padEnd(22) + '0/5'.padStart(7) + '1/5'.padStart(7) + '2/5'.padStart(7) + '3+/5'.padStart(8) + ' ≥1 Hit%'.padStart(10) + ' ≥2 Hit%'.padStart(10) + ' Avg Hits'.padStart(12));
console.log('-'.repeat(85));
const summary = [];
for (const [name, hits] of Object.entries(modelHits)) {
const n = modelCounts[name];
const at1 = n - hits[0];
const at2 = hits[2] + hits[3] + hits[4] + hits[5];
const at3 = hits[3] + hits[4] + hits[5];
const avgHits = Object.entries(hits).reduce((s, [k, v]) => s + parseInt(k) * v, 0) / n;
summary.push({ name, hits, n, at1, at2, at3, at1Pct: at1 / n, at2Pct: at2 / n, avgHits });
console.log(
name.padEnd(22) +
`${((hits[0]/n)*100).toFixed(1)}%`.padStart(7) +
`${((hits[1]/n)*100).toFixed(1)}%`.padStart(7) +
`${((hits[2]/n)*100).toFixed(1)}%`.padStart(7) +
`${((at3/n)*100).toFixed(1)}%`.padStart(8) +
`${(at1/n*100).toFixed(2)}%`.padStart(10) +
`${(at2/n*100).toFixed(2)}%`.padStart(10) +
`${avgHits.toFixed(3)}`.padStart(12)
);
}
// Sort by at-least-1 pct
summary.sort((a, b) => b.at1Pct - a.at1Pct);
const winner = summary[0];
console.log(`\n🏆 Best model for ≥1 hit: ${winner.name}${(winner.at1Pct * 100).toFixed(2)}%`);
console.log(` (vs current M1: ${(summary.find(s=>s.name==='M1_Current').at1Pct*100).toFixed(2)}%)`);
console.log(` Random baseline: ~51.64%`);
// Save results
fs.writeFileSync('model_comparison.json', JSON.stringify({ summary, winner: winner.name, generatedAt: new Date().toISOString() }, null, 2));
console.log('\n✅ model_comparison.json saved');