競馬予測AIの作成㉓(競馬予測モデル分析レポートを作成するWebアプリ)  

はじめに

競馬予測モデルの精度向上を目指し、様々なアルゴリズムとアンサンブル手法を駆使して複数の予測モデルを開発してきました。
ただ、作成した予測モデルの数が増えると「どのモデルが最適なのか」という判断が難しくなっていました。

そこで、各モデルの性能を客観的に比較できるWebアプリを作成しました。
このWebアプリでは、単勝、複勝、馬連、ワイド、三連複といった各ベットタイプごとの的中率を一覧表示で確認できます。
また、視覚的な分析を容易にするためのグラフ機能も実装しています。
従来は単勝的中率のみを基準にモデル選択を行っていましたが、このWebアプリでは単勝的中率に加え、R2スコア、RMSE、MAEといった複数の評価指標を統合した総合スコアを算出できます。

このWebアプリを用いることで、各予測モデルの性能を比較・分析することができます。

Webアプリで確認できること

Webアプリは3つのタブから構成されています。
各タブで確認できることを解説します。

「的中率分析」タブで確認できること

  1. 複数ベットタイプの比較
    単勝、複勝、馬連、ワイド、三連複の各ベットタイプごとに的中率を一覧表示します。これにより、どのモデルがどの馬券タイプに強いのかを確認できます。
  2. 8種類のモデル比較
    XGBoost、LightGBM、CatBoostといった単体の機械学習モデルから、平均アンサンブル、重み付きアンサンブル、スタッキングアンサンブル、投票アンサンブル、ブレンディングアンサンブルまで、異なるアプローチの予測モデルを比較できます。
  3. 最高的中率の可視化
    最も高い的中率を記録したモデルはハイライト表示されます。訓練データ年ごとの最高値(黄色)と全体での最高値(赤色)が直感的に区別できます。

<「的中率分析」タブのサンプル画面>

「性能指標」タブで確認できること

  1. カスタマイズ可能なスコア計算
    単勝的中率、R2スコア、RMSE(平均二乗誤差の平方根)、MAE(平均絶対誤差)の4つの指標を組み合わせた総合スコアを算出します。単に的中するかどうかだけでなく、予測の正確さや一貫性も考慮した評価が可能になります。
  2. 最高スコアの可視化
    総合スコアが最も高いモデルは自動的にハイライト表示されます。

<「性能指標」タブのサンプル画面>

「グラフ分析」タブで確認できること

  1. 訓練データ別モデル性能推移
    異なる訓練データセット(例:2021-2025年、2022-2025年など)でのモデル性能の変化を折れ線グラフで表示します。
  2. ベットタイプ切り替え機能
    グラフ表示するベットタイプを単勝、複勝、馬連、ワイド、三連複の中から選択できます。
  3. モデル間の相対的強さの把握
    全モデルを同一グラフ上に表示することで、それぞれの強みと弱みを相対的に把握できます。

<「グラフ分析」タブのサンプル画面>

ファイル構成

下記の5つのファイルで構成されています。

ファイル名 概要
model_performance_viewer.html UIの基本構造を定義
scripts.js データ処理とUIインタラクションを担当
styles.css 視覚デザインを定義
model_results_viewer.bat Webアプリ起動用のバッチファイル
folder_list.json 利用可能な予測モデルフォルダの管理

動作フロー

動作フローは以下の通りです。

  1. 起動フェーズ
    ・バッチファイルがPythonのHTTPサーバーを起動(ポート8000)
    ・デフォルトブラウザでHTMLファイルを開く
  2. データ読み込みフェーズ
    ・JavaScriptがfolder_list.jsonを読み込む
    ・ユーザーが選択したフォルダから予測結果CSV(的中率結果.csv、的中率結果_with_score.csv)を読み込み
    ・CSVデータを処理
  3. データ処理フェーズ
    ・モデル・訓練データごとのパフォーマンス指標を抽出
    ・ベットタイプ別の的中率を計算
    ・総合スコアの算出(ユーザー定義の重み付けに基づく)
  4. 表示フェーズ
    ・的中率データをテーブル形式で表示
    ・性能指標を整形して表示
    ・Chart.jsを使用してグラフを描画
  5. インタラクションフェーズ
    ・ユーザーのタブ切り替え操作に応じたコンテンツ表示
    ・重み付け調整による総合スコア再計算
    ・ベットタイプ選択に応じたグラフ更新

Webアプリの実行方法

事前準備

事前に予測モデルを作成します。
予測モデルの作成方法は下記を参照してください。

競馬予測AIの作成㉒(競馬順位予測モデルの改修) – リラックスした生活を過ごすために

実行手順

  1. model_results_viewer.bat ファイルをダブルクリックします
  2. バッチファイルが自動的に以下の処理を行います
    ・必要なファイルの存在確認
    ・Python HTTPサーバーの起動(ポート8000)
    ・デフォルトWebブラウザでアプリケーションを開く

正常に起動すると、コマンドプロンプトウィンドウが開き、「Press any key to continue…」というメッセージが表示されます。このウィンドウは閉じないでください(アプリケーション実行中はバックグラウンドで動作しています)。

終了手順

  1. コマンドプロンプトウィンドウ(黒い画面)をアクティブにします
  2. キーボードの任意のキーを押すと、Pythonサーバーが終了します
  3. ブラウザのタブは手動で閉じてください

全コード

model_performance_viewer.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>競馬予測モデル 分析レポート</title>
    <link rel="stylesheet" href="styles.css">
    <script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
</head>
<body>
    <div class="container">
        <h1>競馬予測モデル 分析レポート</h1>

        <div class="tab-container">
            <button class="tab-button active" onclick="showTab('accuracy')">的中率分析</button>
            <button class="tab-button" onclick="showTab('metrics')">性能指標</button>
            <button class="tab-button" onclick="showTab('charts')">グラフ分析</button>
        </div>

        <div class="controls">
            <div class="select-container">
                <label for="folderSelect">実行結果:</label>
                <select id="folderSelect">
                    <option value="">選択してください</option>
                </select>
                <button onclick="refreshFolders()" class="refresh-button">更新</button>
            </div>
        </div>

        <!-- 的中率分析タブ -->
        <div id="accuracy" class="tab-content active">
            <!-- 単勝セクション -->
            <div class="rate-section">
                <h2>単勝</h2>
                <table class="results-table">
                    <thead>
                        <tr>
                            <th>訓練データ</th>
                            <th>XGBoost</th>
                            <th>LightGBM</th>
                            <th>CatBoost</th>
                            <th>平均アンサンブル</th>
                            <th>重み付きアンサンブル</th>
                            <th>スタッキングアンサンブル</th>
                            <th>投票アンサンブル</th>
                            <th>ブレンディングアンサンブル</th>
                        </tr>
                    </thead>
                    <tbody id="win_results"></tbody>
                </table>
            </div>

            <!-- 複勝セクション -->
            <div class="rate-section">
                <h2>複勝</h2>
                <table class="results-table">
                    <thead>
                        <tr>
                            <th>訓練データ</th>
                            <th>XGBoost</th>
                            <th>LightGBM</th>
                            <th>CatBoost</th>
                            <th>平均アンサンブル</th>
                            <th>重み付きアンサンブル</th>
                            <th>スタッキングアンサンブル</th>
                            <th>投票アンサンブル</th>
                            <th>ブレンディングアンサンブル</th>
                        </tr>
                    </thead>
                    <tbody id="place_results"></tbody>
                </table>
            </div>

            <!-- 馬連セクション -->
            <div class="rate-section">
                <h2>馬連</h2>
                <table class="results-table">
                    <thead>
                        <tr>
                            <th>訓練データ</th>
                            <th>XGBoost</th>
                            <th>LightGBM</th>
                            <th>CatBoost</th>
                            <th>平均アンサンブル</th>
                            <th>重み付きアンサンブル</th>
                            <th>スタッキングアンサンブル</th>
                            <th>投票アンサンブル</th>
                            <th>ブレンディングアンサンブル</th>
                        </tr>
                    </thead>
                    <tbody id="quinella_results"></tbody>
                </table>
            </div>

            <!-- ワイドセクション -->
            <div class="rate-section">
                <h2>ワイド</h2>
                <table class="results-table">
                    <thead>
                        <tr>
                            <th>訓練データ</th>
                            <th>XGBoost</th>
                            <th>LightGBM</th>
                            <th>CatBoost</th>
                            <th>平均アンサンブル</th>
                            <th>重み付きアンサンブル</th>
                            <th>スタッキングアンサンブル</th>
                            <th>投票アンサンブル</th>
                            <th>ブレンディングアンサンブル</th>
                        </tr>
                    </thead>
                    <tbody id="wide_results"></tbody>
                </table>
            </div>

            <!-- 三連複セクション -->
            <div class="rate-section">
                <h2>三連複</h2>
                <table class="results-table">
                    <thead>
                        <tr>
                            <th>訓練データ</th>
                            <th>XGBoost</th>
                            <th>LightGBM</th>
                            <th>CatBoost</th>
                            <th>平均アンサンブル</th>
                            <th>重み付きアンサンブル</th>
                            <th>スタッキングアンサンブル</th>
                            <th>投票アンサンブル</th>
                            <th>ブレンディングアンサンブル</th>
                        </tr>
                    </thead>
                    <tbody id="trio_results"></tbody>
                </table>
            </div>
        </div>

        <!-- 性能指標タブ -->
        <div id="metrics" class="tab-content">
            <div class="metrics-section">
                <div class="formula-section">
                    <h3>スコア計算式の概要</h3>
                    <div class="formula-content">
                        <p><strong>総合スコア</strong> = (w1 × 単勝的中率) + (w2 × R2スコア) + (w3 × 正規化RMSE) + (w4 × 正規化MAE)</p>
                        <ul>
                            <li>w1, w2, w3, w4 は重み付け係数(合計 = 1.0)</li>
                            <li>単勝的中率は0-1の範囲に正規化</li>
                            <li>正規化RMSE = 1 / (1 + RMSE)</li>
                            <li>正規化MAE = 1 / (1 + MAE)</li>
                        </ul>
                    </div>
                </div>
                <div class="weight-controls">
                    <h3>スコア計算の重み付け設定</h3>
                    <div class="weight-inputs">
                        <div class="weight-input">
                            <label for="winRateWeight">単勝的中率の重み:</label>
                            <input type="number" id="winRateWeight" value="0.4" step="0.1" min="0" max="1">
                        </div>
                        <div class="weight-input">
                            <label for="r2Weight">R2スコアの重み:</label>
                            <input type="number" id="r2Weight" value="0.3" step="0.1" min="0" max="1">
                        </div>
                        <div class="weight-input">
                            <label for="rmseWeight">RMSEの重み:</label>
                            <input type="number" id="rmseWeight" value="0.2" step="0.1" min="0" max="1">
                        </div>
                        <div class="weight-input">
                            <label for="maeWeight">MAEの重み:</label>
                            <input type="number" id="maeWeight" value="0.1" step="0.1" min="0" max="1">
                        </div>
                    </div>
                    <button onclick="recalculateScores()" class="calculate-button">スコアを再計算</button>
                    <div id="weightWarning" class="weight-warning"></div>
                </div>
                <h2>モデル性能指標</h2>
                <table class="metrics-table">
                    <thead>
                        <tr>
                            <th>訓練データ</th>
                            <th>モデル</th>
                            <th>単勝的中率</th>
                            <th>R2スコア</th>
                            <th>RMSE</th>
                            <th>MAE</th>
                            <th>総合スコア</th>
                        </tr>
                    </thead>
                    <tbody id="metrics_results">
                        <tr>
                            <td colspan="7" class="loading">データを選択してください</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>

        <div id="charts" class="tab-content">
            <div class="chart-section">
                <h2>モデル別性能比較</h2>
                <div class="chart-controls">
                    <div class="select-container">
                        <label for="betTypeSelect">ベットタイプ:</label>
                        <select id="betTypeSelect">
                            <option value="win">単勝</option>
                            <option value="place">複勝</option>
                            <option value="quinella">馬連</option>
                            <option value="wide">ワイド</option>
                            <option value="trio">三連複</option>
                        </select>
                    </div>
                    <div class="select-container">
                        <label for="yearSelect">訓練データ:</label>
                        <select id="yearSelect">
                            <!-- 動的に生成される -->
                        </select>
                    </div>
                </div>
                <div class="chart-container">
                    <canvas id="modelComparisonChart"></canvas>
                </div>
            </div>
        </div>
    </div>

    <script src="scripts.js"></script>
</body>
</html>

scripts.js

const MODEL_CONFIGS = [
    { name: 'XGBoost', id: 'xgboost' },
    { name: 'LightGBM', id: 'lightgbm' },
    { name: 'CatBoost', id: 'catboost' },
    { name: '平均アンサンブル', id: 'average_ensemble' },
    { name: '重み付きアンサンブル', id: 'weighted_ensemble' },
    { name: 'スタッキングアンサンブル', id: 'stacking_ensemble' },
    { name: '投票アンサンブル', id: 'voting_ensemble' },
    { name: 'ブレンディングアンサンブル', id: 'blending_ensemble' }
];

let currentMetricsData = [];
let rawCSVData = []; // 生データ保持用
let yearlyTrendChart = null; // 訓練データ別推移グラフオブジェクト

// ベットタイプのマッピング
const BET_TYPE_MAPPING = {
    'win': 1, // CSVの列インデックス
    'place': 2,
    'quinella': 3,
    'wide': 4,
    'trio': 5
};

// ベットタイプの表示名
const BET_TYPE_NAMES = {
    'win': '単勝',
    'place': '複勝',
    'quinella': '馬連',
    'wide': 'ワイド',
    'trio': '三連複'
};

// グラフ用の色定義
const CHART_COLORS = [
    'rgba(75, 192, 192, 0.7)',
    'rgba(255, 99, 132, 0.7)',
    'rgba(54, 162, 235, 0.7)',
    'rgba(255, 206, 86, 0.7)',
    'rgba(153, 102, 255, 0.7)',
    'rgba(255, 159, 64, 0.7)',
    'rgba(199, 199, 199, 0.7)',
    'rgba(83, 102, 255, 0.7)'
];

// タブ切り替え関数
function showTab(tabName) {
    document.querySelectorAll('.tab-content').forEach(content => {
        content.classList.remove('active');
    });
    document.querySelectorAll('.tab-button').forEach(button => {
        button.classList.remove('active');
    });
    
    document.getElementById(tabName).classList.add('active');
    document.querySelector(`[onclick="showTab('${tabName}')"]`).classList.add('active');
    
    // グラフタブが選択された場合、グラフを初期化
    if (tabName === 'charts' && rawCSVData && rawCSVData.length > 0) {
        setTimeout(() => {
            initializeCharts();
        }, 100); // 少し遅延させてDOM更新を確実にする
    }
}

// フォルダ一覧の更新
async function refreshFolders() {
    try {
        const response = await fetch('./folder_list.json', {
            cache: 'no-store',
            headers: {
                'Cache-Control': 'no-cache',
                'Pragma': 'no-cache'
            }
        });
        console.log('Response status:', response.status);
        if (!response.ok) throw new Error('フォルダリストの取得に失敗しました');
        
        const folders = await response.json();
        console.log('Received folders:', folders);
        const select = document.getElementById('folderSelect');
        const currentValue = select.value;
        
        // プルダウンをクリア
        while (select.options.length > 1) {
            select.remove(1);
        }
        
        // 新しいオプションを追加
        folders.sort().reverse().forEach(folder => {
            const option = new Option(formatFolderName(folder), folder);
            select.add(option);
        });
        
        if (currentValue && folders.includes(currentValue)) {
            select.value = currentValue;
        } else if (folders.length > 0) {
            // 新しいフォルダが選択されるようにする
            select.value = folders[0];
        }
        
        console.log('Loaded folders:', folders);
        
        // フォルダリスト更新後にデータを読み込む
        if (select.value) {
            await loadAllData();
        }
    } catch (error) {
        console.error('フォルダリストの更新に失敗:', error);
        showError('フォルダリストの更新に失敗しました。');
    }
}

// フォルダ名のフォーマット
function formatFolderName(folder) {
    const date = folder.substring(0, 8);
    const time = folder.substring(9);
    return `${date.substring(0,4)}/${date.substring(4,6)}/${date.substring(6,8)} ${time.substring(0,2)}:${time.substring(2,4)}:${time.substring(4)}`;
}

// エラー表示
function showError(message) {
    const errorMessage = `<tr><td colspan="7" class="error">${message}</td></tr>`;
    document.querySelectorAll('tbody').forEach(tbody => {
        tbody.innerHTML = errorMessage;
    });
}

// モデルスコア計算
function calculateModelScore(winRate, r2, rmse, mae, weights) {
    console.log('Calculating score with inputs:', {
        winRate, r2, rmse, mae, weights
    });
    
    try {
        // 文字列から数値への変換と%記号の除去
        const winRateValue = parseFloat(winRate.replace('%', '')) / 100;
        const r2Value = parseFloat(r2);
        const rmseValue = parseFloat(rmse);
        const maeValue = parseFloat(mae);

        console.log('Parsed values:', {
            winRateValue, r2Value, rmseValue, maeValue
        });

        // RMSEとMAEの正規化(score_calculator.pyと同じ方法)
        const normalizedRmse = 1 / (1 + rmseValue);
        const normalizedMae = 1 / (1 + maeValue);

        const score = (weights.winRate * winRateValue +
                     weights.r2 * r2Value +
                     weights.rmse * normalizedRmse +
                     weights.mae * normalizedMae);
                     
        console.log('Calculated score:', score);
        return score.toFixed(4);
    } catch (error) {
        console.error('Error in calculateModelScore:', error);
        return '0.0000';
    }
}

// 重み検証
function validateWeights() {
    const winRateWeight = parseFloat(document.getElementById('winRateWeight').value);
    const r2Weight = parseFloat(document.getElementById('r2Weight').value);
    const rmseWeight = parseFloat(document.getElementById('rmseWeight').value);
    const maeWeight = parseFloat(document.getElementById('maeWeight').value);

    const sum = winRateWeight + r2Weight + rmseWeight + maeWeight;
    const warningElement = document.getElementById('weightWarning');
    
    if (Math.abs(sum - 1.0) > 0.0001) {
        warningElement.textContent = `警告: 重みの合計が1になっていません(現在: ${sum.toFixed(2)})`;
        return null;
    }
    
    warningElement.textContent = '';
    return {
        winRate: winRateWeight,
        r2: r2Weight,
        rmse: rmseWeight,
        mae: maeWeight
    };
}

// スコア再計算
function recalculateScores() {
    console.log('Recalculating scores...');
    const weights = validateWeights();
    console.log('Weights:', weights);
    if (!weights) return;

    console.log('Current metrics data:', currentMetricsData);
    let maxScore = -Infinity;

    // 各データのスコアを再計算
    currentMetricsData.forEach(data => {
        console.log('Processing data:', data);
        const newScore = calculateModelScore(
            data.winRate,
            data.r2,
            data.rmse,
            data.mae,
            weights
        );
        console.log('New score calculated:', newScore);
        data.score = newScore;
        maxScore = Math.max(maxScore, parseFloat(newScore));
    });

    // テーブルを更新
    const tbody = document.getElementById('metrics_results');
    tbody.innerHTML = '';
    
    currentMetricsData
        .sort((a, b) => b.year.localeCompare(a.year) || a.model.localeCompare(b.model))
        .forEach(record => {
            const row = tbody.insertRow();
            row.innerHTML = `
                <td>${record.year}</td>
                <td>${record.model}</td>
                <td>${record.winRate}</td>
                <td>${record.r2}</td>
                <td>${record.rmse}</td>
                <td>${record.mae}</td>
                <td class="score-column" ${parseFloat(record.score) === maxScore ? 'style="background-color: #FFCDD2;"' : ''}>${record.score}</td>
            `;
        });
}

// メトリクスデータ読み込み
async function loadMetrics() {
    const selectedFolder = document.getElementById('folderSelect').value;
    if (!selectedFolder) {
        document.getElementById('metrics_results').innerHTML = 
            '<tr><td colspan="7" class="loading">データを選択してください</td></tr>';
        return;
    }

    try {
        document.getElementById('metrics_results').innerHTML = 
            '<tr><td colspan="7" class="loading">データを読み込み中...</td></tr>';

        const response = await fetch(`./${selectedFolder}/的中率結果_with_score.csv`);
        if (!response.ok) throw new Error('データの取得に失敗しました');
        
        const text = await response.text();
        const rows = text.split('\n')
            .map(row => row.trim())
            .filter(row => row.length > 0)
            .map(row => row.split(',').map(cell => cell.trim()));
        
        // ヘッダーを除去してデータ行のみを使用
        const data = rows.slice(1).filter(row => row.length > 6);
        
        console.log('メトリクスCSVヘッダー:', rows[0]);
        console.log('メトリクス最初のデータ行:', data[0]);
        
        // グローバル変数をクリア
        currentMetricsData = [];

        data.forEach(row => {
            if (row.length <= 6) return;
            
            const path = row[6] || '';
            const yearMatch = path.match(/traindata_(\d{4}_\d{4})/i);
            if (!yearMatch) return;
            
            const year = yearMatch[1];
            let modelFound = false;
            
            for (const model of MODEL_CONFIGS) {
                if (path.toLowerCase().includes(model.id.toLowerCase())) {
                    // CSVの列インデックスに合わせて調整
                    currentMetricsData.push({
                        year,
                        model: model.name,
                        winRate: row[1] || '0%',  // P列 - 単勝
                        r2: row[8] || '0',       // R2列
                        rmse: row[9] || '0',     // RMSE列
                        mae: row[10] || '0',     // MAE列
                        score: row.length > 11 ? row[11] : '0.0000' // XRA列
                    });
                    modelFound = true;
                    break;
                }
            }
            
            if (!modelFound) {
                console.warn('モデルIDが見つからないパス:', path);
            }
        });

        console.log('メトリクスデータ行数:', currentMetricsData.length);

        // 表示を更新
        const tbody = document.getElementById('metrics_results');
        tbody.innerHTML = '';
        
        if (currentMetricsData.length === 0) {
            tbody.innerHTML = '<tr><td colspan="7" class="error">データが見つかりませんでした</td></tr>';
            return;
        }
        
        const maxScore = Math.max(...currentMetricsData.map(record => parseFloat(record.score) || 0));

        currentMetricsData
            .sort((a, b) => b.year.localeCompare(a.year) || a.model.localeCompare(b.model))
            .forEach(record => {
                const row = tbody.insertRow();
                row.innerHTML = `
                    <td>${record.year}</td>
                    <td>${record.model}</td>
                    <td>${record.winRate}</td>
                    <td>${record.r2}</td>
                    <td>${record.rmse}</td>
                    <td>${record.mae}</td>
                    <td class="score-column" ${parseFloat(record.score) === maxScore ? 'style="background-color: #FFCDD2;"' : ''}>${record.score}</td>
                `;
            });

    } catch (error) {
        console.error('メトリクスの読み込みに失敗:', error);
        document.getElementById('metrics_results').innerHTML = 
            '<tr><td colspan="7" class="error">データを読み込めませんでした: ' + error.message + '</td></tr>';
    }
}

// 結果データ読み込み
async function loadResults() {
    const selectedFolder = document.getElementById('folderSelect').value;
    if (!selectedFolder) return;

    try {
        // データ読み込み中の表示
        ['win', 'place', 'quinella', 'wide', 'trio'].forEach(betType => {
            document.getElementById(`${betType}_results`).innerHTML = 
                '<tr><td colspan="9" class="loading">データを読み込み中...</td></tr>';
        });

        const response = await fetch(`./${selectedFolder}/的中率結果.csv`);
        if (!response.ok) throw new Error(`CSVファイルの取得に失敗: ${response.status}`);
        
        const csvText = await response.text();
        console.log('CSV読み込み完了, 長さ:', csvText.length);
        
        // CSVをパースする - カンマ区切りの列を処理
        const rows = csvText.split('\n').map(row => 
            row.trim().split(',').map(cell => cell.trim())
        );
        
        console.log('CSVヘッダー:', rows[0]);
        if (rows.length > 1) console.log('最初のデータ行:', rows[1]);
        
        // 有効なデータ行のみフィルタリング
        rawCSVData = rows.slice(1).filter(row => row.length > 6);
        console.log('有効データ行数:', rawCSVData.length);
        
        // パス列(6列目)からの訓練データ抽出を試みる
        const yearsFromPath = [];
        const modelIds = [];
        
        rawCSVData.forEach(row => {
            if (row.length <= 6) return;
            
            const path = row[6] || '';
            
            // traindata_YYYY_YYYY 形式の訓練データを抽出
            const yearMatch = path.match(/traindata_(\d{4}_\d{4})/i);
            if (yearMatch) {
                const year = yearMatch[1];
                if (!yearsFromPath.includes(year)) {
                    yearsFromPath.push(year);
                }
            }
            
            // モデルIDを抽出
            MODEL_CONFIGS.forEach(model => {
                if (path.toLowerCase().includes(model.id.toLowerCase()) && 
                    !modelIds.includes(model.id)) {
                    modelIds.push(model.id);
                }
            });
        });
        
        console.log('抽出された訓練データ:', yearsFromPath);
        console.log('抽出されたモデルID:', modelIds);
        
        // 訓練データが見つからない場合、代替方法で抽出を試みる
        const years = yearsFromPath.length > 0 ? 
            yearsFromPath.sort().reverse() : 
            ['2024_2025', '2023_2025', '2022_2025', '2021_2025']; // デフォルト訓練データ
        
        const maxValues = {
            win: { byYear: {}, overall: 0 },
            place: { byYear: {}, overall: 0 },
            quinella: { byYear: {}, overall: 0 },
            wide: { byYear: {}, overall: 0 },
            trio: { byYear: {}, overall: 0 }
        };

        // 列インデックスを修正 - CSV形式に合わせる
        const betTypeIndices = {
            'win': 1,    // P列 - 単勝
            'place': 2,  // ¡列 - 複勝
            'quinella': 3, // nA列 - 馬連
            'wide': 4,   // Ch列 - ワイド
            'trio': 5    // OA¡列 - 三連複
        };

        // 最大値を計算
        rawCSVData.forEach(row => {
            Object.entries(betTypeIndices).forEach(([betType, index]) => {
                if (row.length <= index) return;
                const value = parseFloat(row[index]);
                if (!isNaN(value) && value > maxValues[betType].overall) {
                    maxValues[betType].overall = value;
                }
            });
        });

        // 各ベットタイプのテーブルを生成
        Object.entries(betTypeIndices).forEach(([betType, betIndex]) => {
            const tbody = document.getElementById(`${betType}_results`);
            let tbodyHtml = '';

            years.forEach(year => {
                tbodyHtml += '<tr>';
                tbodyHtml += `<td>${year}</td>`;

                MODEL_CONFIGS.forEach(model => {
                    const modelData = rawCSVData.find(row => {
                        if (row.length <= 6) return false;
                        const path = (row[6] || '').toLowerCase();
                        const yearMatch = path.includes(`traindata_${year.toLowerCase()}`);
                        const modelMatch = path.includes(model.id.toLowerCase());
                        return yearMatch && modelMatch;
                    });

                    if (modelData && modelData.length > betIndex) {
                        const value = modelData[betIndex];
                        const numValue = parseFloat(value);
                        
                        if (!isNaN(numValue)) {
                            if (!maxValues[betType].byYear[year] || numValue > maxValues[betType].byYear[year]) {
                                maxValues[betType].byYear[year] = numValue;
                            }
                            tbodyHtml += `<td id="${betType}_${year}_${model.id}">${value}</td>`;
                        } else {
                            tbodyHtml += '<td>-</td>';
                        }
                    } else {
                        tbodyHtml += '<td>-</td>';
                    }
                });

                tbodyHtml += '</tr>';
            });

            tbody.innerHTML = tbodyHtml;
            console.log(`${betType}テーブル更新, HTML長さ:`, tbodyHtml.length);

            // 最大値のハイライト処理
            years.forEach(year => {
                if (maxValues[betType].byYear[year]) {
                    MODEL_CONFIGS.forEach(model => {
                        const cell = document.getElementById(`${betType}_${year}_${model.id}`);
                        if (cell) {
                            const value = parseFloat(cell.textContent);
                            if (!isNaN(value)) {
                                if (value === maxValues[betType].overall) {
                                    cell.style.backgroundColor = '#FFCDD2';
                                } else if (value === maxValues[betType].byYear[year]) {
                                    cell.style.backgroundColor = '#FFF9C4';
                                }
                            }
                        }
                    });
                }
            });
        });

        // グラフに使用するデータも更新
        rawCSVData = rawCSVData.filter(row => row.length > 6);
        
        // グラフの初期化
        initializeCharts();

    } catch (error) {
        console.error('結果の読み込みに失敗:', error);
        document.querySelectorAll('tbody').forEach(tbody => {
            tbody.innerHTML = '<tr><td colspan="9" class="error">データを読み込めませんでした: ' + error.message + '</td></tr>';
        });
    }
}

// CSVデータから訓練データを抽出する関数
function extractYearsFromData() {
    // 重複のない訓練データを格納するためのSetを作成
    const yearsSet = new Set();
    
    // rawCSVDataから訓練データを抽出
    rawCSVData.forEach(row => {
        if (row.length <= 6) return;
        
        const path = row[6] || '';
        // traindata_YYYY_YYYY パターンの訓練データを抽出
        const yearMatch = path.match(/traindata_(\d{4}_\d{4})/i);
        if (yearMatch) {
            yearsSet.add(yearMatch[1]);
        }
    });
    
    // 配列に変換
    let years = Array.from(yearsSet);
    
    // データから訓練データが見つからない場合はデフォルト値を使用
    if (years.length === 0) {
        const currentYear = new Date().getFullYear();
        years = [];
        // 現在から10年前までの訓練データを生成
        for (let i = currentYear - 10; i <= currentYear; i++) {
            years.push(`${i}_${currentYear}`);
        }
    }
    
    // 訓練データを降順(最新のものが先頭)にソート
    return years.sort().reverse();
}

// 訓練データごとのモデル性能推移グラフを生成
function createYearlyTrendChart() {
    // 既存のチャートがあれば破棄
    if (yearlyTrendChart) {
        yearlyTrendChart.destroy();
    }
    
    const selectedBetType = document.getElementById('betTypeSelect').value;
    const betIndex = BET_TYPE_MAPPING[selectedBetType];
    const betTypeName = BET_TYPE_NAMES[selectedBetType];
    
    // 訓練データデータを動的に取得(ハードコードの代わり)
    const allYears = extractYearsFromData();
    
    // データセットを生成して最大値・最小値を計算
    let overallMaxValue = 0;
    let overallMinValue = 100; // 的中率の初期最小値を100%と仮定
    let hasValidData = false;
    
    const datasets = MODEL_CONFIGS.map((model, index) => {
        const data = allYears.map(year => {
            // 各訓練データ・各モデルのデータを検索
            const modelData = rawCSVData.find(row => {
                if (row.length <= 6) return false;
                const path = (row[6] || '').toLowerCase();
                return path.includes(model.id.toLowerCase()) && 
                       path.includes(`traindata_${year.toLowerCase()}`);
            });
            
            // データがあれば値を、なければnullを返す
            const value = modelData && modelData.length > betIndex ? 
                         parseFloat(modelData[betIndex]) : null;
                         
            // 最大値・最小値を更新
            if (value !== null) {
                hasValidData = true;
                overallMaxValue = Math.max(overallMaxValue, value);
                overallMinValue = Math.min(overallMinValue, value);
            }
            
            return value;
        });
        
        return {
            label: model.name,
            data: data,
            borderColor: CHART_COLORS[index].replace('0.7', '1'),
            backgroundColor: CHART_COLORS[index],
            pointRadius: 4,
            pointHoverRadius: 6,
            tension: 0.1,
            fill: false
        };
    });
    
    // 有効なデータがない場合は初期値を設定
    if (!hasValidData) {
        overallMaxValue = 100;
        overallMinValue = 0;
    }
    
    // 最大値に5%のマージンを追加
    const yAxisMax = Math.min(105, Math.ceil(overallMaxValue + 5));
    
    // 最小値から5%のマージンを引く(ただし0未満にはならないようにする)
    const yAxisMin = Math.max(0, Math.floor(overallMinValue - 5));
    
    // 目盛りの間隔を計算(表示範囲に基づいて適切な間隔を決定)
    const range = yAxisMax - yAxisMin;
    let stepSize;
    if (range <= 20) stepSize = 2;
    else if (range <= 50) stepSize = 5;
    else if (range <= 100) stepSize = 10;
    else stepSize = 20;
    
    // グラフを作成
    const canvas = document.getElementById('yearlyTrendChart');
    if (!canvas) return;
    
    const ctx = canvas.getContext('2d');
    yearlyTrendChart = new Chart(ctx, {
        type: 'line',
        data: {
            labels: allYears,
            datasets: datasets
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            plugins: {
                title: {
                    display: true,
                    text: `${betTypeName}の訓練データ別モデル性能推移`,
                    font: {
                        size: 18,
                        weight: 'bold'
                    }
                },
                legend: {
                    position: 'bottom',
                    labels: {
                        boxWidth: 12,
                        usePointStyle: true
                    }
                },
                tooltip: {
                    callbacks: {
                        label: function(context) {
                            return `${context.dataset.label}: ${context.raw}%`;
                        }
                    }
                }
            },
            scales: {
                y: {
                    min: yAxisMin,
                    max: yAxisMax,
                    ticks: {
                        callback: function(value) {
                            return value + '%';
                        },
                        stepSize: stepSize
                    },
                    title: {
                        display: true,
                        text: '的中率 (%)'
                    }
                },
                x: {
                    title: {
                        display: true,
                        text: '訓練データ'
                    }
                }
            }
        }
    });
}

// グラフ初期化関数
function initializeCharts() {
    if (!document.getElementById('charts').classList.contains('active')) {
        return;
    }
    
    const betTypeSelect = document.getElementById('betTypeSelect');
    if (!betTypeSelect) return;
    
    // 訓練データ別推移グラフの初期化
    createYearlyTrendChart();
    
    // イベントリスナーを設定
    betTypeSelect.addEventListener('change', () => {
        createYearlyTrendChart();
    });
}

// すべてのデータ読み込み
async function loadAllData() {
    await loadResults();
    await loadMetrics();
    
    // チャート初期化
    if (document.getElementById('charts').classList.contains('active')) {
        initializeCharts();
    }
}

// グラフスタイル
const graphStyles = `
.chart-section {
    margin-bottom: 40px;
    background-color: white;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.chart-section + .chart-section {
    margin-top: 30px;
}

.chart-container {
    height: 500px;
    margin: 0 auto;
    position: relative;
}

#yearlyTrendChart {
    margin-top: 20px;
}
`;

// スタイルを追加する関数
function addGraphStyles() {
    const styleElement = document.createElement('style');
    styleElement.textContent = graphStyles;
    document.head.appendChild(styleElement);
}

// DOMContentLoaded時の初期化
document.addEventListener('DOMContentLoaded', () => {
    // グラフ分析タブの内容を書き換え
    const chartsTab = document.getElementById('charts');
    if (chartsTab) {
        chartsTab.innerHTML = `
            <div class="chart-section">
                <h2>訓練データ別モデル性能推移</h2>
                <div class="chart-controls">
                    <div class="select-container">
                        <label for="betTypeSelect">ベットタイプ:</label>
                        <select id="betTypeSelect">
                            <option value="win">単勝</option>
                            <option value="place">複勝</option>
                            <option value="quinella">馬連</option>
                            <option value="wide">ワイド</option>
                            <option value="trio">三連複</option>
                        </select>
                    </div>
                </div>
                <div class="chart-container">
                    <canvas id="yearlyTrendChart"></canvas>
                </div>
            </div>
        `;
    }
    
    // フォルダリスト更新
    refreshFolders();
    
    // 更新ボタンのイベントリスナーを追加
    const refreshButton = document.querySelector('.refresh-button');
    if (refreshButton) {
        refreshButton.addEventListener('click', async (e) => {
            e.preventDefault();
            await refreshFolders();
        });
    }
    
    // グラフスタイルを追加
    addGraphStyles();
});

// フォルダ選択ドロップダウンのイベントリスナー
document.addEventListener('DOMContentLoaded', () => {
    const folderSelect = document.getElementById('folderSelect');
    if (folderSelect) {
        folderSelect.addEventListener('change', loadAllData);
    }
});

styles.css

body {
    font-family: 'Segoe UI', Arial, sans-serif;
    line-height: 1.6;
    margin: 0;
    padding: 20px;
    background-color: #f5f5f5;
}

.formula-section {
    background-color: #f8f9fa;
    padding: 20px;
    border-radius: 8px;
    margin-bottom: 20px;
    border: 1px solid #e9ecef;
}

.formula-content {
    margin-top: 15px;
    line-height: 1.6;
}

.formula-content p {
    margin: 10px 0;
}

.formula-content ul {
    margin: 10px 0;
    padding-left: 25px;
}

.formula-content li {
    margin: 5px 0;
}

.container {
    max-width: 1400px;
    margin: 0 auto;
    background-color: white;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

h1, h2, h3 {
    text-align: center;
    color: #333;
    margin-bottom: 20px;
}

.tab-container {
    display: flex;
    justify-content: center;
    gap: 10px;
    margin-bottom: 20px;
}

.tab-button {
    padding: 10px 20px;
    font-size: 16px;
    border: none;
    background-color: #f0f0f0;
    cursor: pointer;
    border-radius: 4px;
    transition: background-color 0.3s;
}

.tab-button.active {
    background-color: #007bff;
    color: white;
}

.tab-content {
    display: none;
}

.tab-content.active {
    display: block;
}

.controls {
    margin-bottom: 30px;
}

.select-container {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    margin-bottom: 20px;
}

select {
    padding: 8px 16px;
    font-size: 16px;
    border-radius: 4px;
    border: 1px solid #ddd;
    min-width: 250px;
}

.refresh-button {
    padding: 8px 16px;
    background-color: #f0f0f0;
    border: 1px solid #ddd;
    border-radius: 4px;
    cursor: pointer;
}

.refresh-button:hover {
    background-color: #e0e0e0;
}

.results-table,
.metrics-table {
    width: 100%;
    border-collapse: collapse;
    margin-top: 10px;
}

.results-table th,
.results-table td,
.metrics-table th,
.metrics-table td {
    border: 1px solid #ddd;
    padding: 8px;
    text-align: center;
}

.results-table th,
.metrics-table th {
    background-color: #f8f9fa;
    font-weight: bold;
}

.results-table tr:nth-child(even),
.metrics-table tr:nth-child(even) {
    background-color: #f9f9f9;
}

.rate-section {
    margin-bottom: 40px;
}

.score-column {
    font-weight: bold;
    color: #007bff;
}

.loading {
    text-align: center;
    padding: 20px;
    font-style: italic;
    color: #666;
}

.error {
    color: #dc3545;
    text-align: center;
    padding: 20px;
}

.no-data {
    text-align: center;
    color: #666;
    padding: 20px;
}

.weight-controls {
    background-color: #f8f9fa;
    padding: 20px;
    border-radius: 8px;
    margin-bottom: 20px;
}

.weight-controls h3 {
    margin-top: 0;
    margin-bottom: 15px;
}

.weight-inputs {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 15px;
    margin-bottom: 15px;
}

.weight-input {
    display: flex;
    flex-direction: column;
    gap: 5px;
}

.weight-input input {
    padding: 8px;
    border: 1px solid #ddd;
    border-radius: 4px;
    width: 100px;
}

.calculate-button {
    background-color: #007bff;
    color: white;
    padding: 8px 16px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    transition: background-color 0.2s;
}

.calculate-button:hover {
    background-color: #0056b3;
}

.weight-warning {
    color: #dc3545;
    margin-top: 10px;
    font-size: 0.9em;
}

.chart-section {
    margin-bottom: 40px;
    background-color: white;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.chart-controls {
    display: flex;
    justify-content: center;
    gap: 20px;
    margin-bottom: 20px;
}

.chart-container {
    height: 400px;
    margin: 0 auto;
    position: relative;
}

/* タブコンテンツに関するCSSを修正(既存のものを上書き) */
.tab-content {
    display: none;
}

.tab-content.active {
    display: block;
}

model_results_viewer.bat

@echo off
REM 必要なファイルの存在確認
SET files_missing=0

REM HTMLビューワーと関連ファイルの確認
IF NOT EXIST "model_performance_viewer.html" SET files_missing=1
IF NOT EXIST "scripts.js" SET files_missing=1
IF NOT EXIST "folder_list.json" SET files_missing=1
IF NOT EXIST "styles.css" SET files_missing=1

IF %files_missing%==1 (
    REM エラーメッセージを表示
    echo 必要なファイルが見つかりません。
    powershell -Command "& {[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('必要なファイル(model_performance_viewer.html、scripts.js、folder_list.json、styles.css)が見つかりません。' + [char]13 + [char]10 + 'ファイルを正しく配置してください。', 'エラー', 'OK', 'Error')}"
    pause
    exit
)

REM 的中率結果.csvと的中率結果_with_score.csvの存在確認
SET data_found=0

REM サブディレクトリ含めて探索
FOR /R %%G IN ("的中率結果.csv") DO (
    IF EXIST "%%G" SET data_found=1
)

FOR /R %%G IN ("的中率結果_with_score.csv") DO (
    IF EXIST "%%G" SET data_found=1
)

IF %data_found%==0 (
    REM エラーメッセージを表示
    echo ファイルが存在しません。モデルを作成してください。
    powershell -Command "& {[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('的中率結果.csvが見つかりません。' + [char]13 + [char]10 + '予測モデルを作成してください。', 'エラー', 'OK', 'Error')}"
    pause
    exit
)

REM ポート8000でPythonのHTTPサーバーを起動
start python -m http.server 8000

REM サーバーが起動するのを待つ(3秒程度待機)
timeout /t 3 /nobreak > nul

REM 指定されたURLをデフォルトブラウザで開く
start http://localhost:8000/model_performance_viewer.html

REM サーバーを終了せず、このウィンドウを開いたままにする
pause

以上です。

コメント

タイトルとURLをコピーしました