QuestionDifficultyCalibrationAnalyzer.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. <?php
  2. namespace App\Services\Analytics;
  3. use Illuminate\Support\Carbon;
  4. use Illuminate\Support\Facades\DB;
  5. use Illuminate\Support\Facades\Schema;
  6. /**
  7. * 从做题与错题数据抽取「题库标定难度 vs 实测正确率」等指标,用于检验难度体系是否合理。
  8. *
  9. * 说明:当前库表未见独立「学生逐题自评难易」字段;{@see self::parsePaperDifficultyCategory()}
  10. * 将 papers.difficulty_category 解析为数值,作为「本次练习/学案侧难度选择」的代理变量。
  11. */
  12. class QuestionDifficultyCalibrationAnalyzer
  13. {
  14. /**
  15. * @param array{
  16. * min_attempts?: int,
  17. * since?: Carbon|null,
  18. * include_mistakes?: bool,
  19. * student_id?: string|int|null,
  20. * question_bank_id?: int|null,
  21. * question_code?: string|null,
  22. * calibration_min_attempts?: int,
  23. * alpha?: float,
  24. * max_step?: float,
  25. * half_life_days?: int
  26. * } $options
  27. * @return array<string, mixed>
  28. */
  29. public function run(array $options = []): array
  30. {
  31. $minAttempts = max(1, (int) ($options['min_attempts'] ?? 5));
  32. $since = $options['since'] ?? null;
  33. $includeMistakes = (bool) ($options['include_mistakes'] ?? true);
  34. $studentId = isset($options['student_id']) && $options['student_id'] !== '' && $options['student_id'] !== null
  35. ? (string) $options['student_id']
  36. : null;
  37. $questionBankId = isset($options['question_bank_id']) ? (int) $options['question_bank_id'] : null;
  38. if ($questionBankId === 0) {
  39. $questionBankId = null;
  40. }
  41. $questionCode = isset($options['question_code']) ? trim((string) $options['question_code']) : '';
  42. if ($questionCode !== '' && Schema::hasTable('questions')) {
  43. $resolved = DB::table('questions')->where('question_code', $questionCode)->value('id');
  44. if ($resolved === null) {
  45. return [
  46. 'ok' => false,
  47. 'error' => '未找到 question_code='.$questionCode.' 对应的题库题目',
  48. ];
  49. }
  50. $questionBankId = (int) $resolved;
  51. }
  52. if (! Schema::hasTable('paper_questions') || ! Schema::hasTable('papers')) {
  53. return [
  54. 'ok' => false,
  55. 'error' => '缺少必要数据表 paper_questions 或 papers',
  56. ];
  57. }
  58. // 4 条硬约束参数(可通过命令行覆盖)
  59. $calibrationMinAttempts = max(1, (int) ($options['calibration_min_attempts'] ?? 10));
  60. $alpha = (float) ($options['alpha'] ?? 0.2);
  61. $alpha = max(0.01, min(1.0, $alpha));
  62. $maxStep = (float) ($options['max_step'] ?? 0.03);
  63. $maxStep = max(0.001, min(0.2, $maxStep));
  64. $halfLifeDays = max(1, (int) ($options['half_life_days'] ?? 30));
  65. $perQuestion = $this->aggregatePerQuestion($minAttempts, $since, $studentId, $questionBankId);
  66. $byPaperDifficulty = $this->aggregatePerQuestionByPaperDifficulty($since, $studentId, $questionBankId);
  67. $bankDiffs = [];
  68. $errorRates = [];
  69. foreach ($perQuestion as $row) {
  70. $d = self::normalizeDifficulty($row['bank_difficulty'] ?? null);
  71. if ($d === null) {
  72. continue;
  73. }
  74. $n = (int) $row['attempts'];
  75. if ($n < 1) {
  76. continue;
  77. }
  78. $acc = (float) $row['correct_count'] / $n;
  79. $bankDiffs[] = $d;
  80. $errorRates[] = 1.0 - $acc;
  81. }
  82. $bins = $this->binByDifficulty($perQuestion);
  83. $pearson = $this->pearsonCorrelation($bankDiffs, $errorRates);
  84. $paperLevelRows = $this->rowLevelPaperDifficultyVsOutcome($since, $studentId);
  85. $mistakeByBankId = [];
  86. if ($includeMistakes && Schema::hasTable('mistake_records')) {
  87. $mistakeByBankId = $this->mistakeCountsByQuestionBankId($studentId);
  88. }
  89. $merged = [];
  90. foreach ($perQuestion as $row) {
  91. $bid = (int) $row['question_bank_id'];
  92. $norm = self::normalizeDifficulty($row['bank_difficulty'] ?? null);
  93. $emp = $row['attempts'] > 0
  94. ? 1.0 - ((float) $row['correct_count'] / (int) $row['attempts'])
  95. : null;
  96. $gap = ($emp !== null && $norm !== null) ? round($emp - $norm, 4) : null;
  97. $strata = $byPaperDifficulty[$bid] ?? [];
  98. $calibration = $this->buildCalibrationRecommendation(
  99. $norm,
  100. $strata,
  101. $calibrationMinAttempts,
  102. $alpha,
  103. $maxStep,
  104. $halfLifeDays
  105. );
  106. $merged[] = array_merge($row, [
  107. 'wrong_count' => max(0, (int) $row['attempts'] - (int) $row['correct_count']),
  108. 'bank_difficulty_normalized' => $norm,
  109. 'empirical_error_rate' => $emp,
  110. /** 实测错误率 − 题库难度(0–1):越大表示相对标定「更难做对」 */
  111. 'calibration_gap' => $gap,
  112. 'mistake_records_count' => $mistakeByBankId[$bid] ?? 0,
  113. 'paper_difficulty_breakdown' => $strata,
  114. 'calibration_weighted_error_rate' => $calibration['weighted_error_rate'],
  115. 'calibration_effective_attempts' => $calibration['effective_attempts'],
  116. 'calibration_recommendation' => $calibration['recommendation'],
  117. ]);
  118. }
  119. return [
  120. 'ok' => true,
  121. 'meta' => [
  122. 'min_attempts' => $minAttempts,
  123. 'since' => $since?->toIso8601String(),
  124. 'student_id' => $studentId,
  125. 'question_bank_id' => $questionBankId,
  126. 'question_rows' => count($perQuestion),
  127. 'note' => '无独立「学生逐题自评难易」字段;mistake_records 为错题本行数。下列「每题一行」为 paper_questions 已判分聚合。',
  128. 'calibration_constraints' => [
  129. 'stratified_by' => 'papers.difficulty_category',
  130. 'min_attempts' => $calibrationMinAttempts,
  131. 'alpha' => $alpha,
  132. 'max_step' => $maxStep,
  133. 'time_decay_half_life_days' => $halfLifeDays,
  134. ],
  135. ],
  136. 'summary' => [
  137. 'pearson_bank_difficulty_vs_empirical_error_rate' => $pearson,
  138. 'interpretation' => $this->interpretPearson($pearson),
  139. 'pearson_paper_difficulty_category_vs_incorrect' => $paperLevelRows['pearson_category_vs_incorrect'] ?? null,
  140. 'interpretation_paper_category' => $this->interpretPearson($paperLevelRows['pearson_category_vs_incorrect'] ?? null),
  141. ],
  142. 'bins_by_bank_difficulty' => $bins,
  143. 'paper_difficulty_category_vs_incorrect_rate' => $paperLevelRows,
  144. 'per_question' => $merged,
  145. ];
  146. }
  147. /**
  148. * @return list<array<string, mixed>>
  149. */
  150. private function aggregatePerQuestion(int $minAttempts, ?Carbon $since, ?string $studentId, ?int $questionBankId): array
  151. {
  152. $q = DB::table('paper_questions as pq')
  153. ->join('papers as p', 'p.paper_id', '=', 'pq.paper_id')
  154. ->leftJoin('questions as qu', 'qu.id', '=', 'pq.question_bank_id')
  155. ->whereNotNull('pq.is_correct')
  156. ->whereNotNull('pq.question_bank_id');
  157. if ($studentId !== null) {
  158. $q->where('p.student_id', $studentId);
  159. }
  160. if ($questionBankId !== null) {
  161. $q->where('pq.question_bank_id', $questionBankId);
  162. }
  163. if ($since !== null) {
  164. $q->where(function ($w) use ($since) {
  165. $w->where('pq.updated_at', '>=', $since)
  166. ->orWhere('pq.graded_at', '>=', $since);
  167. });
  168. }
  169. $rows = $q
  170. ->groupBy('pq.question_bank_id')
  171. ->havingRaw('COUNT(*) >= ?', [$minAttempts])
  172. ->selectRaw('
  173. pq.question_bank_id as question_bank_id,
  174. COUNT(*) as attempts,
  175. SUM(CASE WHEN pq.is_correct = 1 THEN 1 ELSE 0 END) as correct_count,
  176. AVG(pq.difficulty) as avg_paper_question_difficulty,
  177. MAX(qu.difficulty) as bank_difficulty,
  178. MAX(qu.question_code) as question_code
  179. ')
  180. ->get();
  181. return $rows->map(fn ($r) => [
  182. 'question_bank_id' => (int) $r->question_bank_id,
  183. 'question_code' => $r->question_code,
  184. 'attempts' => (int) $r->attempts,
  185. 'correct_count' => (int) $r->correct_count,
  186. 'accuracy' => $r->attempts > 0 ? round((int) $r->correct_count / (int) $r->attempts, 4) : null,
  187. 'avg_paper_question_difficulty' => $r->avg_paper_question_difficulty !== null ? (float) $r->avg_paper_question_difficulty : null,
  188. 'bank_difficulty' => $r->bank_difficulty !== null ? (float) $r->bank_difficulty : null,
  189. ])->all();
  190. }
  191. /**
  192. * 分层统计:每道题在不同 papers.difficulty_category 下的对错分布。
  193. *
  194. * @return array<int, list<array<string, mixed>>>
  195. */
  196. private function aggregatePerQuestionByPaperDifficulty(?Carbon $since, ?string $studentId, ?int $questionBankId): array
  197. {
  198. $q = DB::table('paper_questions as pq')
  199. ->join('papers as p', 'p.paper_id', '=', 'pq.paper_id')
  200. ->whereNotNull('pq.is_correct')
  201. ->whereNotNull('pq.question_bank_id');
  202. if ($studentId !== null) {
  203. $q->where('p.student_id', $studentId);
  204. }
  205. if ($questionBankId !== null) {
  206. $q->where('pq.question_bank_id', $questionBankId);
  207. }
  208. if ($since !== null) {
  209. $q->where(function ($w) use ($since) {
  210. $w->where('pq.updated_at', '>=', $since)
  211. ->orWhere('pq.graded_at', '>=', $since);
  212. });
  213. }
  214. $rows = $q->groupBy('pq.question_bank_id', 'p.difficulty_category')
  215. ->selectRaw('
  216. pq.question_bank_id as question_bank_id,
  217. p.difficulty_category as difficulty_category,
  218. COUNT(*) as attempts,
  219. SUM(CASE WHEN pq.is_correct = 1 THEN 1 ELSE 0 END) as correct_count,
  220. SUM(CASE WHEN pq.is_correct = 0 THEN 1 ELSE 0 END) as wrong_count,
  221. MAX(COALESCE(pq.graded_at, pq.updated_at, pq.created_at)) as last_answered_at
  222. ')
  223. ->get();
  224. $out = [];
  225. foreach ($rows as $r) {
  226. $bid = (int) $r->question_bank_id;
  227. $attempts = (int) $r->attempts;
  228. $wrong = (int) $r->wrong_count;
  229. $out[$bid] ??= [];
  230. $out[$bid][] = [
  231. 'difficulty_category' => $r->difficulty_category,
  232. 'difficulty_category_numeric' => self::parsePaperDifficultyCategory((string) ($r->difficulty_category ?? '')),
  233. 'attempts' => $attempts,
  234. 'correct_count' => (int) $r->correct_count,
  235. 'wrong_count' => $wrong,
  236. 'error_rate' => $attempts > 0 ? round($wrong / $attempts, 4) : null,
  237. 'last_answered_at' => $r->last_answered_at,
  238. ];
  239. }
  240. return $out;
  241. }
  242. /**
  243. * 逐条作答:学案 difficulty_category(解析为 0–4 等级,再 /4 归一化)与是否做错(0/1)的 Pearson 相关。
  244. *
  245. * @return array{n_rows: int, n_rows_with_category: int, pearson_category_vs_incorrect: ?float, by_category: list<array<string, mixed>>}
  246. */
  247. private function rowLevelPaperDifficultyVsOutcome(?Carbon $since, ?string $studentId): array
  248. {
  249. $q = DB::table('paper_questions as pq')
  250. ->join('papers as p', 'p.paper_id', '=', 'pq.paper_id')
  251. ->whereNotNull('pq.is_correct');
  252. if ($studentId !== null) {
  253. $q->where('p.student_id', $studentId);
  254. }
  255. if ($since !== null) {
  256. $q->where(function ($w) use ($since) {
  257. $w->where('pq.updated_at', '>=', $since)
  258. ->orWhere('pq.graded_at', '>=', $since);
  259. });
  260. }
  261. $rows = $q->select(['pq.is_correct', 'p.difficulty_category'])->get();
  262. $byCat = [];
  263. foreach ($rows as $r) {
  264. $cat = self::parsePaperDifficultyCategory($r->difficulty_category ?? null);
  265. $key = $cat === null ? '_unknown' : (string) $cat;
  266. if (! isset($byCat[$key])) {
  267. $byCat[$key] = ['category' => $cat, 'n' => 0, 'incorrect' => 0];
  268. }
  269. $byCat[$key]['n']++;
  270. $incorrect = ((int) $r->is_correct) === 0 ? 1 : 0;
  271. $byCat[$key]['incorrect'] += $incorrect;
  272. }
  273. $outBy = [];
  274. foreach ($byCat as $v) {
  275. $n = $v['n'];
  276. $outBy[] = [
  277. 'difficulty_category_numeric' => $v['category'],
  278. 'n' => $n,
  279. 'incorrect_rate' => $n > 0 ? round($v['incorrect'] / $n, 4) : null,
  280. ];
  281. }
  282. usort($outBy, fn ($a, $b) => ($a['difficulty_category_numeric'] ?? -1) <=> ($b['difficulty_category_numeric'] ?? -1));
  283. $xs = [];
  284. $ys = [];
  285. foreach ($rows as $r) {
  286. $cat = self::parsePaperDifficultyCategory($r->difficulty_category ?? null);
  287. if ($cat === null) {
  288. continue;
  289. }
  290. $xs[] = $cat / 4.0;
  291. $ys[] = ((int) $r->is_correct) === 0 ? 1.0 : 0.0;
  292. }
  293. return [
  294. 'n_rows' => $rows->count(),
  295. 'n_rows_with_category' => count($xs),
  296. 'pearson_category_vs_incorrect' => $this->pearsonCorrelation($xs, $ys),
  297. 'by_category' => $outBy,
  298. ];
  299. }
  300. /**
  301. * @return array<int, int> question_bank_id => mistake 行数(学生维度错题本条目)
  302. */
  303. private function mistakeCountsByQuestionBankId(?string $studentId): array
  304. {
  305. $mq = DB::table('mistake_records')
  306. ->selectRaw('question_id, COUNT(*) as c')
  307. ->groupBy('question_id');
  308. if ($studentId !== null) {
  309. $mq->where('student_id', $studentId);
  310. }
  311. $counts = $mq->pluck('c', 'question_id')->all();
  312. $byBank = [];
  313. foreach ($counts as $qid => $c) {
  314. if (! is_numeric($qid)) {
  315. continue;
  316. }
  317. $bankId = (int) $qid;
  318. $byBank[$bankId] = ($byBank[$bankId] ?? 0) + (int) $c;
  319. }
  320. return $byBank;
  321. }
  322. /**
  323. * @param list<array<string, mixed>> $perQuestion
  324. * @return list<array<string, mixed>>
  325. */
  326. private function binByDifficulty(array $perQuestion): array
  327. {
  328. $edges = [0.0, 0.25, 0.5, 0.75, 1.0];
  329. $bins = [];
  330. for ($i = 0; $i < count($edges) - 1; $i++) {
  331. $bins[] = [
  332. 'min' => $edges[$i],
  333. 'max' => $edges[$i + 1],
  334. 'n_questions' => 0,
  335. 'total_attempts' => 0,
  336. 'total_correct' => 0,
  337. 'mean_accuracy' => null,
  338. ];
  339. }
  340. foreach ($perQuestion as $row) {
  341. $d = self::normalizeDifficulty($row['bank_difficulty'] ?? null);
  342. if ($d === null) {
  343. continue;
  344. }
  345. // [0,0.25), [0.25,0.5), [0.5,0.75), [0.75,1.0]
  346. $binIdx = (int) floor(min(0.999999, max(0.0, $d)) / 0.25);
  347. if ($binIdx > 3) {
  348. $binIdx = 3;
  349. }
  350. if ($binIdx < 0) {
  351. $binIdx = 0;
  352. }
  353. $bins[$binIdx]['n_questions']++;
  354. $bins[$binIdx]['total_attempts'] += (int) $row['attempts'];
  355. $bins[$binIdx]['total_correct'] += (int) $row['correct_count'];
  356. }
  357. foreach ($bins as &$b) {
  358. if ($b['total_attempts'] > 0) {
  359. $b['mean_accuracy'] = round($b['total_correct'] / $b['total_attempts'], 4);
  360. }
  361. }
  362. unset($b);
  363. return $bins;
  364. }
  365. private function interpretPearson(?float $r): string
  366. {
  367. if ($r === null) {
  368. return '样本不足或难度无变异,无法计算相关系数。';
  369. }
  370. if ($r > 0.15) {
  371. return '题库难度与实测错误率呈正相关:标定越高的题,学生越容易错,方向符合预期。';
  372. }
  373. if ($r < -0.15) {
  374. return '出现负相关:标定「难」的题反而正确率更高,建议检查标定、题型或样本偏差。';
  375. }
  376. return '相关较弱:标定难度与实测区分度不明显,可能样本量、标定噪声或题目同质性导致。';
  377. }
  378. /**
  379. * 将 papers.difficulty_category 解析为 0–4 的等级,再归一化到 0–1(便于与 0–1 题库难度对照)。
  380. */
  381. public static function parsePaperDifficultyCategory(?string $raw): ?float
  382. {
  383. if ($raw === null) {
  384. return null;
  385. }
  386. $s = strtolower(trim((string) $raw));
  387. if ($s === '') {
  388. return null;
  389. }
  390. if (is_numeric($s)) {
  391. $n = (int) $s;
  392. return (float) max(0, min(4, $n));
  393. }
  394. // 与业务侧 0–4 档一致:0 基础 / 1 筑基 / 2 提分 / 3 培优 / 4 竞赛(与 MasteryCalculator 区间命名对齐)
  395. $level = match ($s) {
  396. '0', '零基础', '0基础', '基础', '0级' => 0.0,
  397. '1', '筑基' => 1.0,
  398. '2', '进阶', '中等', '提分' => 2.0,
  399. '3', '培优' => 3.0,
  400. '4', '竞赛' => 4.0,
  401. default => null,
  402. };
  403. return $level;
  404. }
  405. public static function normalizeDifficulty(?float $d): ?float
  406. {
  407. if ($d === null) {
  408. return null;
  409. }
  410. $f = (float) $d;
  411. return $f > 1.0 ? $f / 5.0 : $f;
  412. }
  413. /**
  414. * @param list<float> $x
  415. * @param list<float> $y
  416. */
  417. private function pearsonCorrelation(array $x, array $y): ?float
  418. {
  419. $n = count($x);
  420. if ($n < 3 || count($y) !== $n) {
  421. return null;
  422. }
  423. $mx = array_sum($x) / $n;
  424. $my = array_sum($y) / $n;
  425. $num = 0.0;
  426. $dx = 0.0;
  427. $dy = 0.0;
  428. for ($i = 0; $i < $n; $i++) {
  429. $vx = $x[$i] - $mx;
  430. $vy = $y[$i] - $my;
  431. $num += $vx * $vy;
  432. $dx += $vx * $vx;
  433. $dy += $vy * $vy;
  434. }
  435. $den = sqrt($dx * $dy);
  436. return $den > 1e-12 ? round($num / $den, 4) : null;
  437. }
  438. /**
  439. * 在四条硬约束下给出每题的动态难度建议。
  440. *
  441. * 约束:
  442. * 1) 分层:先按 papers.difficulty_category 切分;
  443. * 2) 样本门槛:有效样本不足则不动;
  444. * 3) 平滑 + 限幅:delta = clip(alpha * gap, -maxStep, maxStep);
  445. * 4) 时间衰减:分层样本按最近作答时间加权(半衰期 halfLifeDays)。
  446. *
  447. * @param list<array<string, mixed>> $strata
  448. * @return array{
  449. * weighted_error_rate:?float,
  450. * effective_attempts:float,
  451. * recommendation:array{
  452. * action:string,
  453. * reason:string,
  454. * gap:?float,
  455. * delta:?float,
  456. * suggested_difficulty:?float
  457. * }
  458. * }
  459. */
  460. private function buildCalibrationRecommendation(
  461. ?float $bankDifficultyNormalized,
  462. array $strata,
  463. int $minAttempts,
  464. float $alpha,
  465. float $maxStep,
  466. int $halfLifeDays
  467. ): array {
  468. if ($bankDifficultyNormalized === null) {
  469. return [
  470. 'weighted_error_rate' => null,
  471. 'effective_attempts' => 0.0,
  472. 'recommendation' => [
  473. 'action' => 'hold',
  474. 'reason' => '题库难度为空,无法计算建议。',
  475. 'gap' => null,
  476. 'delta' => null,
  477. 'suggested_difficulty' => null,
  478. ],
  479. ];
  480. }
  481. $now = Carbon::now();
  482. $weightedAttempts = 0.0;
  483. $weightedWrong = 0.0;
  484. foreach ($strata as $s) {
  485. $attempts = (int) ($s['attempts'] ?? 0);
  486. $wrong = (int) ($s['wrong_count'] ?? 0);
  487. if ($attempts <= 0) {
  488. continue;
  489. }
  490. $lastAtRaw = $s['last_answered_at'] ?? null;
  491. $days = 0.0;
  492. if ($lastAtRaw) {
  493. try {
  494. $lastAt = Carbon::parse((string) $lastAtRaw);
  495. $days = max(0.0, (float) $lastAt->diffInDays($now));
  496. } catch (\Throwable) {
  497. $days = 0.0;
  498. }
  499. }
  500. $w = pow(0.5, $days / $halfLifeDays);
  501. $weightedAttempts += $attempts * $w;
  502. $weightedWrong += $wrong * $w;
  503. }
  504. if ($weightedAttempts <= 0.0) {
  505. return [
  506. 'weighted_error_rate' => null,
  507. 'effective_attempts' => 0.0,
  508. 'recommendation' => [
  509. 'action' => 'hold',
  510. 'reason' => '无有效样本,保持不变。',
  511. 'gap' => null,
  512. 'delta' => null,
  513. 'suggested_difficulty' => round($bankDifficultyNormalized, 4),
  514. ],
  515. ];
  516. }
  517. $weightedErrorRate = $weightedWrong / $weightedAttempts;
  518. $gap = $weightedErrorRate - $bankDifficultyNormalized;
  519. if ($weightedAttempts < $minAttempts) {
  520. return [
  521. 'weighted_error_rate' => round($weightedErrorRate, 4),
  522. 'effective_attempts' => round($weightedAttempts, 2),
  523. 'recommendation' => [
  524. 'action' => 'hold',
  525. 'reason' => '有效样本不足门槛 '.$minAttempts.',仅观测不调整。',
  526. 'gap' => round($gap, 4),
  527. 'delta' => 0.0,
  528. 'suggested_difficulty' => round($bankDifficultyNormalized, 4),
  529. ],
  530. ];
  531. }
  532. $delta = max(-$maxStep, min($maxStep, $alpha * $gap));
  533. $suggested = max(0.0, min(1.0, $bankDifficultyNormalized + $delta));
  534. $eps = 1e-6;
  535. $action = $delta > $eps ? 'increase' : ($delta < -$eps ? 'decrease' : 'hold');
  536. $reason = match ($action) {
  537. 'increase' => '实测(分层+时衰)错误率高于标定,建议小步上调。',
  538. 'decrease' => '实测(分层+时衰)错误率低于标定,建议小步下调。',
  539. default => 'gap 接近 0,建议保持不变。',
  540. };
  541. return [
  542. 'weighted_error_rate' => round($weightedErrorRate, 4),
  543. 'effective_attempts' => round($weightedAttempts, 2),
  544. 'recommendation' => [
  545. 'action' => $action,
  546. 'reason' => $reason,
  547. 'gap' => round($gap, 4),
  548. 'delta' => round($delta, 4),
  549. 'suggested_difficulty' => round($suggested, 4),
  550. ],
  551. ];
  552. }
  553. }