AnalyzeQuestionDifficultyCalibrationCommand.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Models\Question;
  4. use App\Services\Analytics\QuestionDifficultyCalibrationAnalyzer;
  5. use Carbon\Carbon;
  6. use Illuminate\Console\Command;
  7. use Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\File;
  9. class AnalyzeQuestionDifficultyCalibrationCommand extends Command
  10. {
  11. protected $signature = 'questions:difficulty-calibration-report
  12. {--min-attempts=5 : 每题至少多少次已判分作答才纳入}
  13. {--since= : 仅统计该日期之后(含),Y-m-d}
  14. {--no-mistakes : 不合并 mistake_records}
  15. {--calibration-min-attempts=10 : 动态调整生效的最小有效样本(硬约束)}
  16. {--alpha=0.2 : 平滑系数(硬约束)}
  17. {--max-step=0.03 : 单次调整最大幅度(硬约束)}
  18. {--half-life-days=30 : 时间衰减半衰期(天,硬约束)}
  19. {--student= : 只统计该 student_id(papers.student_id)下的作答}
  20. {--question-bank-id= : 只分析该题库主键 id(questions.id)}
  21. {--question-code= : 只分析该题编码(questions.question_code)}
  22. {--limit=80 : 终端打印多少行具体题目;0 表示全部}
  23. {--sort=attempts : 排序:attempts|gap_desc|gap_asc(gap=实测错误率−题库难度0~1)}
  24. {--with-stem : 为每行截取题干前 60 字(仅建议与 --limit 联用)}
  25. {--with-aggregate : 额外打印 Pearson、分箱等汇总}
  26. {--json= : 完整 JSON 路径}
  27. {--csv= : per_question CSV 路径}';
  28. protected $description = '按「每一道做过的题」输出具体次数、对错、题库难度等(paper_questions 聚合)';
  29. public function handle(QuestionDifficultyCalibrationAnalyzer $analyzer): int
  30. {
  31. $since = $this->option('since')
  32. ? Carbon::parse((string) $this->option('since'))->startOfDay()
  33. : null;
  34. $qbId = $this->option('question-bank-id');
  35. $minAttempts = (int) $this->option('min-attempts');
  36. if ($qbId !== null && $qbId !== '' && $minAttempts === 5) {
  37. $minAttempts = 1;
  38. }
  39. $report = $analyzer->run([
  40. 'min_attempts' => $minAttempts,
  41. 'since' => $since,
  42. 'include_mistakes' => ! $this->option('no-mistakes'),
  43. 'calibration_min_attempts' => (int) $this->option('calibration-min-attempts'),
  44. 'alpha' => (float) $this->option('alpha'),
  45. 'max_step' => (float) $this->option('max-step'),
  46. 'half_life_days' => (int) $this->option('half-life-days'),
  47. 'student_id' => $this->option('student'),
  48. 'question_bank_id' => $qbId !== null && $qbId !== '' ? (int) $qbId : null,
  49. 'question_code' => $this->option('question-code') ? (string) $this->option('question-code') : null,
  50. ]);
  51. if (! ($report['ok'] ?? false)) {
  52. $this->error($report['error'] ?? '分析失败');
  53. return self::FAILURE;
  54. }
  55. $rows = collect($report['per_question'] ?? []);
  56. $meta = $report['meta'] ?? [];
  57. $this->line('筛选条件: min_attempts='.($meta['min_attempts'] ?? '')
  58. .($meta['since'] ? ' since='.$meta['since'] : '')
  59. .($meta['student_id'] ? ' student_id='.$meta['student_id'] : '')
  60. .($meta['question_bank_id'] ? ' question_bank_id='.$meta['question_bank_id'] : ''));
  61. $constraints = $meta['calibration_constraints'] ?? [];
  62. if ($constraints !== []) {
  63. $this->line(
  64. '动态约束: stratified_by='.$constraints['stratified_by']
  65. .' min='.$constraints['min_attempts']
  66. .' alpha='.$constraints['alpha']
  67. .' max_step='.$constraints['max_step']
  68. .' half_life_days='.$constraints['time_decay_half_life_days']
  69. );
  70. }
  71. $this->line('命中题数(聚合行数): '.($meta['question_rows'] ?? $rows->count()));
  72. $this->newLine();
  73. $sort = (string) $this->option('sort');
  74. $rows = match ($sort) {
  75. 'gap_desc' => $rows->sortByDesc(fn ($r) => abs((float) ($r['calibration_gap'] ?? 0)))->values(),
  76. 'gap_asc' => $rows->sortBy(fn ($r) => abs((float) ($r['calibration_gap'] ?? 0)))->values(),
  77. default => $rows->sortByDesc('attempts')->values(),
  78. };
  79. $withStem = (bool) $this->option('with-stem');
  80. $stemById = [];
  81. if ($withStem && $rows->isNotEmpty()) {
  82. $ids = $rows->pluck('question_bank_id')->unique()->filter()->all();
  83. $stemById = DB::table('questions')
  84. ->whereIn('id', $ids)
  85. ->pluck('stem', 'id')
  86. ->all();
  87. }
  88. $limit = (int) $this->option('limit');
  89. $slice = $limit === 0 ? $rows : $rows->take($limit);
  90. $tableRows = $slice->map(function (array $r) use ($withStem, $stemById) {
  91. $norm = $r['bank_difficulty_normalized'];
  92. $emp = $r['empirical_error_rate'];
  93. $line = [
  94. (string) ($r['question_bank_id'] ?? ''),
  95. (string) ($r['question_code'] ?? ''),
  96. (string) ($r['attempts'] ?? ''),
  97. (string) ($r['correct_count'] ?? ''),
  98. (string) ($r['wrong_count'] ?? ''),
  99. $r['accuracy'] !== null ? (string) $r['accuracy'] : '',
  100. $r['bank_difficulty'] !== null ? (string) $r['bank_difficulty'] : '',
  101. $norm !== null ? (string) $norm : '',
  102. $r['avg_paper_question_difficulty'] !== null ? (string) round((float) $r['avg_paper_question_difficulty'], 4) : '',
  103. $emp !== null ? (string) round((float) $emp, 4) : '',
  104. $r['calibration_gap'] !== null ? (string) $r['calibration_gap'] : '',
  105. isset($r['calibration_weighted_error_rate']) ? (string) $r['calibration_weighted_error_rate'] : '',
  106. isset($r['calibration_effective_attempts']) ? (string) $r['calibration_effective_attempts'] : '',
  107. (string) (($r['calibration_recommendation']['action'] ?? 'hold')),
  108. isset($r['calibration_recommendation']['delta']) ? (string) $r['calibration_recommendation']['delta'] : '',
  109. isset($r['calibration_recommendation']['suggested_difficulty']) ? (string) $r['calibration_recommendation']['suggested_difficulty'] : '',
  110. (string) ($r['mistake_records_count'] ?? '0'),
  111. ];
  112. if ($withStem) {
  113. $id = (int) ($r['question_bank_id'] ?? 0);
  114. $raw = $stemById[$id] ?? '';
  115. $text = $raw !== '' ? mb_substr(trim(strip_tags($raw)), 0, 60) : '';
  116. $line[] = $text;
  117. }
  118. return $line;
  119. })->all();
  120. $headers = [
  121. '题库id',
  122. 'question_code',
  123. '作答次数',
  124. '对',
  125. '错',
  126. '正确率',
  127. '题库difficulty(原值)',
  128. '题库难度(0~1)',
  129. '卷面难度均值',
  130. '实测错误率',
  131. '标定-实测差(gap)',
  132. '分层时衰错误率',
  133. '有效样本(时衰)',
  134. '建议动作',
  135. '建议delta',
  136. '建议新难度',
  137. '错题本行数',
  138. ];
  139. if ($withStem) {
  140. $headers[] = '题干前60字';
  141. }
  142. $this->info('【每一道做过且已判分的题】——以下为聚合结果(同一题库 id 跨所有相关试卷合并)');
  143. $this->table($headers, $tableRows);
  144. if ($limit > 0 && $rows->count() > $limit) {
  145. $this->comment('仅显示前 '.$limit.' 行,共 '.$rows->count().' 行;加 --limit=0 可输出全部(建议配合 --csv)。');
  146. }
  147. $this->newLine();
  148. $qb = $meta['question_bank_id'] ?? null;
  149. if ($qb !== null) {
  150. $q = Question::query()->find($qb);
  151. if ($q) {
  152. $this->info('【本题题干节选】question_bank_id='.$qb);
  153. $this->line(mb_substr(trim(strip_tags((string) $q->stem)), 0, 400));
  154. $this->newLine();
  155. }
  156. }
  157. if ($this->option('with-aggregate')) {
  158. $s = $report['summary'] ?? [];
  159. $this->line('Pearson(题库0~1难度 vs 实测错误率): '.json_encode($s['pearson_bank_difficulty_vs_empirical_error_rate'] ?? null));
  160. $this->line($s['interpretation'] ?? '');
  161. $this->line('Pearson(学案档位 vs 单次是否错): '.json_encode($s['pearson_paper_difficulty_category_vs_incorrect'] ?? null));
  162. $paper = $report['paper_difficulty_category_vs_incorrect_rate'] ?? [];
  163. $this->table(
  164. ['学案档位(0-4)', '条数', '错误率'],
  165. collect($paper['by_category'] ?? [])->map(fn ($r) => [
  166. $r['difficulty_category_numeric'] ?? 'unknown',
  167. $r['n'],
  168. $r['incorrect_rate'],
  169. ])->all()
  170. );
  171. $this->table(
  172. ['bin_min', 'bin_max', '题数', '总作答', '平均正确率'],
  173. collect($report['bins_by_bank_difficulty'] ?? [])->map(fn ($b) => [
  174. $b['min'], $b['max'], $b['n_questions'], $b['total_attempts'], $b['mean_accuracy'],
  175. ])->all()
  176. );
  177. $this->newLine();
  178. }
  179. $jsonPath = $this->option('json');
  180. if ($jsonPath) {
  181. File::put($jsonPath, json_encode($report, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
  182. $this->info('JSON: '.$jsonPath);
  183. }
  184. $csvPath = $this->option('csv');
  185. if ($csvPath) {
  186. $this->writeCsv($csvPath, $report['per_question'] ?? []);
  187. $this->info('CSV: '.$csvPath);
  188. }
  189. return self::SUCCESS;
  190. }
  191. /**
  192. * @param list<array<string, mixed>> $rows
  193. */
  194. private function writeCsv(string $path, array $rows): void
  195. {
  196. $fh = fopen($path, 'wb');
  197. if ($fh === false) {
  198. $this->error('无法写入 CSV');
  199. return;
  200. }
  201. $headers = [
  202. 'question_bank_id',
  203. 'question_code',
  204. 'attempts',
  205. 'correct_count',
  206. 'wrong_count',
  207. 'accuracy',
  208. 'bank_difficulty',
  209. 'bank_difficulty_normalized',
  210. 'avg_paper_question_difficulty',
  211. 'empirical_error_rate',
  212. 'calibration_gap',
  213. 'calibration_weighted_error_rate',
  214. 'calibration_effective_attempts',
  215. 'recommended_action',
  216. 'recommended_delta',
  217. 'suggested_difficulty',
  218. 'mistake_records_count',
  219. ];
  220. fputcsv($fh, $headers);
  221. foreach ($rows as $r) {
  222. fputcsv($fh, [
  223. $r['question_bank_id'] ?? '',
  224. $r['question_code'] ?? '',
  225. $r['attempts'] ?? '',
  226. $r['correct_count'] ?? '',
  227. $r['wrong_count'] ?? '',
  228. $r['accuracy'] ?? '',
  229. $r['bank_difficulty'] ?? '',
  230. $r['bank_difficulty_normalized'] ?? '',
  231. $r['avg_paper_question_difficulty'] ?? '',
  232. $r['empirical_error_rate'] ?? '',
  233. $r['calibration_gap'] ?? '',
  234. $r['calibration_weighted_error_rate'] ?? '',
  235. $r['calibration_effective_attempts'] ?? '',
  236. $r['calibration_recommendation']['action'] ?? '',
  237. $r['calibration_recommendation']['delta'] ?? '',
  238. $r['calibration_recommendation']['suggested_difficulty'] ?? '',
  239. $r['mistake_records_count'] ?? '',
  240. ]);
  241. }
  242. fclose($fh);
  243. }
  244. }