---
phase: 11-predictv3
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- application/admin/model/History.php
autonomous: true
requirements:
- PRED-02
- PRED-05
must_haves:
truths:
- "用户可以在回测结果中看到 NDCG@5 指标"
- "用户可以在回测结果中看到 MRR 指标"
- "用户可以看到各排名位置的命中分布统计"
- "系统在数据不足时返回合理的默认值或提示"
artifacts:
- path: "application/admin/model/History.php"
provides: "NDCG、MRR、命中分布计算方法"
contains: "_calculateNDCG|_calculateMRR|_calculateHitDistribution"
key_links:
- from: "_runBacktestV3"
to: "_calculateNDCG, _calculateMRR, _calculateHitDistribution"
via: "method call in return statement"
---
# Phase 11 - Plan 01: 回测指标扩展
## Objective
扩展 `_runBacktestV3` 方法的回测指标,新增 NDCG@5、MRR、命中率分布等排名质量评估指标,提升算法评估能力。
**Purpose:** 当前回测仅返回命中率(Top5)和平均排名,缺少排名质量评估指标。NDCG、MRR 是成熟的推荐系统评估指标,能更全面反映预测排名质量。
**Output:** `History.php` 中新增 3 个计算方法,`_runBacktestV3` 返回结果扩展。
## Tasks
### Task 1: 实现 NDCG@5 计算(含空预测保护和公式文档)
- D:\code\php\amlhc\application\admin\model\History.php (line 3495-3560, _runBacktestV3 方法)
在 `History.php` 文件末尾(类内)新增 `_calculateNDCG` 方法:
```php
/**
* 计算 NDCG@K (Normalized Discounted Cumulative Gain)
*
* 公式说明:
* - DCG (Discounted Cumulative Gain) = Σ(rel_i / log2(rank_i + 1))
* 其中 rel_i = 1 (命中) 或 0 (未命中),rank_i 为预测排名位置
* - IDCG (Ideal DCG) = Σ(1 / log2(i + 1)) for i = 1..min(hits, K)
* 即理想情况下所有命中的号码都排在最前面的DCG值
* - NDCG = DCG / IDCG,范围 0-1,越接近1表示排名质量越好
*
* @param array $backtestDetails 回测详情数组,每项包含 {hit: bool, rank: int}
* @param int $K Top-K 参数,默认5,评估前K个预测位置的排名质量
* @return float NDCG值 (0-1范围),空数据时返回0
*/
private function _calculateNDCG($backtestDetails, $K = 5)
{
// 边缘情况处理:空预测或无效参数
if (empty($backtestDetails) || $K <= 0) {
return 0;
}
$dcg = 0;
$idcg = 0;
// 计算 DCG: 命中号码的排名折损累积值
foreach ($backtestDetails as $detail) {
if (!isset($detail['hit']) || !isset($detail['rank'])) {
continue; // 跳过无效数据
}
if ($detail['hit'] && $detail['rank'] > 0 && $detail['rank'] <= $K) {
// DCG公式: rel / log2(rank + 1),命中时 rel=1
$dcg += 1 / log($detail['rank'] + 1, 2);
}
}
// 计算 IDCG: 最理想情况下所有命中的 DCG(假设都排在第1位)
$hitCount = 0;
foreach ($backtestDetails as $detail) {
if (isset($detail['hit']) && $detail['hit']) {
$hitCount++;
}
}
for ($i = 1; $i <= min($hitCount, $K); $i++) {
$idcg += 1 / log($i + 1, 2);
}
// 返回标准化值,IDCG为0时返回0避免除零错误
return $idcg > 0 ? round($dcg / $idcg, 4) : 0;
}
```
实现要点:
- 公式:DCG = Σ(1/log2(rank+1)),IDCG = Σ(1/log2(i+1)) for i=1..hits
- 添加空预测保护:检查 $backtestDetails 是否为空
- 添加数据完整性检查:确保 hit 和 rank 字段存在
- 使用 log(rank + 1, 2) 作为折损函数,排名越靠前权重越高
- 返回 0-1 范围的标准化值,越接近 1 表示排名质量越好
- grep 正则匹配: `_calculateNDCG\s*\(` 在 History.php 中存在
- grep 匹配: `empty($backtestDetails)` 在方法中存在(空预测保护)
- 方法返回 float 类型值
- 包含函数级注释说明 NDCG 计算逻辑和公式
### Task 2: 实现 MRR 和命中分布计算(含边缘情况处理)
- D:\code\php\amlhc\application\admin\model\History.php (新增的 _calculateNDCG 方法位置)
在 `_calculateNDCG` 方法后继续新增 `_calculateMRR` 和 `_calculateHitDistribution` 方法:
```php
/**
* 计算 MRR (Mean Reciprocal Rank)
* 平均倒数排名,关注命中号码的具体排名位置
*
* 公式说明:
* - MRR = Σ(1/rank_i) / N,其中 rank_i 为命中号码的排名,N 为测试总数
* - 未命中的测试项贡献 0 到倒数排名
* - MRR 范围 0-1,越接近1表示命中号码平均排名越靠前
*
* @param array $backtestDetails 回测详情数组,每项包含 {hit: bool, rank: int}
* @return float MRR值 (0-1范围),空数据时返回0
*/
private function _calculateMRR($backtestDetails)
{
// 边缘情况处理:空预测
if (empty($backtestDetails)) {
return 0;
}
$reciprocalRanks = [];
foreach ($backtestDetails as $detail) {
if (!isset($detail['hit']) || !isset($detail['rank'])) {
continue; // 跳过无效数据
}
if ($detail['hit'] && $detail['rank'] > 0) {
$reciprocalRanks[] = 1 / $detail['rank'];
} else {
$reciprocalRanks[] = 0; // 未命中记为0
}
}
return count($reciprocalRanks) > 0
? round(array_sum($reciprocalRanks) / count($reciprocalRanks), 4)
: 0;
}
/**
* 计算命中率分布
* 统计各排名位置(1-5)的命中次数分布
*
* 结构定义:
* - 返回格式: {rank_1: n, rank_2: n, rank_3: n, rank_4: n, rank_5: n}
* - rank_N 表示预测排名第N位的命中次数
* - 用于前端柱状图可视化展示
*
* @param array $backtestDetails 回测详情数组,每项包含 {hit: bool, rank: int}
* @return array 各排名(1-5)的命中次数统计,键名为 rank_1 到 rank_5
*/
private function _calculateHitDistribution($backtestDetails)
{
// 边缘情况处理:空预测返回全0分布
if (empty($backtestDetails)) {
return [
'rank_1' => 0,
'rank_2' => 0,
'rank_3' => 0,
'rank_4' => 0,
'rank_5' => 0
];
}
// 初始化分布数组,键名使用 rank_N 格式便于前端解析
$distribution = [
'rank_1' => 0,
'rank_2' => 0,
'rank_3' => 0,
'rank_4' => 0,
'rank_5' => 0
];
foreach ($backtestDetails as $detail) {
if (!isset($detail['hit']) || !isset($detail['rank'])) {
continue; // 跳过无效数据
}
if ($detail['hit'] && $detail['rank'] >= 1 && $detail['rank'] <= 5) {
$key = 'rank_' . $detail['rank'];
$distribution[$key]++;
}
}
return $distribution;
}
```
实现要点:
- MRR: 命中号码排名倒数平均值,公式 Σ(1/rank)/N
- 命中分布: 明确结构为 `{rank_1: n, rank_2: n, ..., rank_5: n}`
- 两个方法均添加空预测保护和无效数据跳过逻辑
- hit_distribution 使用 rank_N 键名格式,便于前端柱状图渲染
- grep 正则匹配: `_calculateMRR\s*\(` 在 History.php 中存在
- grep 正则匹配: `_calculateHitDistribution\s*\(` 在 History.php 中存在
- grep 匹配: `empty($backtestDetails)` 在两个方法中均存在(空预测保护)
- grep 匹配: `rank_1|rank_2|rank_3|rank_4|rank_5` 在 _calculateHitDistribution 中存在
- 两个方法均包含函数级注释
### Task 3: 扩展 _runBacktestV3 返回结果(含数据量检查)
- D:\code\php\amlhc\application\admin\model\History.php (line 3549-3556, _runBacktestV3 返回语句)
修改 `_runBacktestV3` 方法的返回语句,在原有返回结构中添加新指标和数据量验证:
找到以下代码段(约 line 3549-3556):
```php
return [
'hit_rate' => $hitRate,
'avg_rank' => $avgRank,
'total_tests' => $testCount,
'total_hits' => $hits,
'details' => $details
];
```
替换为:
```php
// 计算新增指标(添加数据量检查)
$minDataThreshold = 50; // 置信度计算最小数据量阈值
// 如果测试数据不足,返回默认值并添加警告
if ($testCount < $minDataThreshold) {
$ndcg5 = 0;
$mrr = 0;
$hitDistribution = [
'rank_1' => 0,
'rank_2' => 0,
'rank_3' => 0,
'rank_4' => 0,
'rank_5' => 0
];
$dataWarning = '回测数据不足(' . $testCount . '期),建议至少50期以获得可靠指标';
} else {
$ndcg5 = $this->_calculateNDCG($details, 5);
$mrr = $this->_calculateMRR($details);
$hitDistribution = $this->_calculateHitDistribution($details);
$dataWarning = null;
}
$precision5 = $testCount > 0 ? round($hits / ($testCount * 5) * 100, 2) : 0;
return [
'hit_rate' => $hitRate,
'avg_rank' => $avgRank,
'total_tests' => $testCount,
'total_hits' => $hits,
'details' => $details,
// 新增排名质量指标
'ndcg_5' => $ndcg5,
'mrr' => $mrr,
'hit_distribution' => $hitDistribution,
'precision_5' => $precision5,
// 数据量警告(不足时提示)
'data_warning' => $dataWarning,
'data_sufficient' => $testCount >= $minDataThreshold
];
```
注意:
- 新增指标计算放在 return 语句之前,确保 $details 数组已完整构建
- 添加最小数据量检查(50期),不足时返回默认值和警告提示
- 新增 data_warning 和 data_sufficient 字段供前端展示
- grep 匹配: `ndcg_5` 在 _runBacktestV3 返回结构中存在
- grep 匹配: `mrr` 在 _runBacktestV3 返回结构中存在
- grep 匹配: `hit_distribution` 在 _runBacktestV3 返回结构中存在
- grep 匹配: `precision_5` 在 _runBacktestV3 返回结构中存在
- grep 匹配: `data_warning` 在 _runBacktestV3 返回结构中存在
- grep 匹配: `minDataThreshold` 变量在方法中存在
## Verification
执行预测接口验证新指标返回:
```bash
curl -s "http://127.0.0.1:8000/admin/history/predictV3?periods=200&backtest=10" | grep -E "ndcg_5|mrr|hit_distribution|precision_5|data_warning"
```
预期结果:返回 JSON 中包含 ndcg_5、mrr、hit_distribution、precision_5、data_warning 字段。
## Success Criteria
1. `_calculateNDCG`、`_calculateMRR`、`_calculateHitDistribution` 三个方法已实现
2. 所有计算方法包含空预测保护和数据完整性检查
3. NDCG 公式在注释中完整说明:DCG = Σ(1/log2(rank+1))
4. hit_distribution 结构明确为 `{rank_1..rank_5: counts}` 格式
5. `_runBacktestV3` 返回结构包含 ndcg_5、mrr、hit_distribution、precision_5、data_warning 字段
6. 添加数据量检查,不足50期时返回警告
7. 所有新增方法包含函数级注释
## Output
完成后创建 `.planning/phases/11-predictv3/11-01-SUMMARY.md`