| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269 |
- <?php
- namespace App\Console\Commands;
- use App\Models\Question;
- use App\Services\Analytics\QuestionDifficultyCalibrationAnalyzer;
- use Carbon\Carbon;
- use Illuminate\Console\Command;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\File;
- class AnalyzeQuestionDifficultyCalibrationCommand extends Command
- {
- protected $signature = 'questions:difficulty-calibration-report
- {--min-attempts=5 : 每题至少多少次已判分作答才纳入}
- {--since= : 仅统计该日期之后(含),Y-m-d}
- {--no-mistakes : 不合并 mistake_records}
- {--calibration-min-attempts=10 : 动态调整生效的最小有效样本(硬约束)}
- {--alpha=0.2 : 平滑系数(硬约束)}
- {--max-step=0.03 : 单次调整最大幅度(硬约束)}
- {--half-life-days=30 : 时间衰减半衰期(天,硬约束)}
- {--student= : 只统计该 student_id(papers.student_id)下的作答}
- {--question-bank-id= : 只分析该题库主键 id(questions.id)}
- {--question-code= : 只分析该题编码(questions.question_code)}
- {--limit=80 : 终端打印多少行具体题目;0 表示全部}
- {--sort=attempts : 排序:attempts|gap_desc|gap_asc(gap=实测错误率−题库难度0~1)}
- {--with-stem : 为每行截取题干前 60 字(仅建议与 --limit 联用)}
- {--with-aggregate : 额外打印 Pearson、分箱等汇总}
- {--json= : 完整 JSON 路径}
- {--csv= : per_question CSV 路径}';
- protected $description = '按「每一道做过的题」输出具体次数、对错、题库难度等(paper_questions 聚合)';
- public function handle(QuestionDifficultyCalibrationAnalyzer $analyzer): int
- {
- $since = $this->option('since')
- ? Carbon::parse((string) $this->option('since'))->startOfDay()
- : null;
- $qbId = $this->option('question-bank-id');
- $minAttempts = (int) $this->option('min-attempts');
- if ($qbId !== null && $qbId !== '' && $minAttempts === 5) {
- $minAttempts = 1;
- }
- $report = $analyzer->run([
- 'min_attempts' => $minAttempts,
- 'since' => $since,
- 'include_mistakes' => ! $this->option('no-mistakes'),
- 'calibration_min_attempts' => (int) $this->option('calibration-min-attempts'),
- 'alpha' => (float) $this->option('alpha'),
- 'max_step' => (float) $this->option('max-step'),
- 'half_life_days' => (int) $this->option('half-life-days'),
- 'student_id' => $this->option('student'),
- 'question_bank_id' => $qbId !== null && $qbId !== '' ? (int) $qbId : null,
- 'question_code' => $this->option('question-code') ? (string) $this->option('question-code') : null,
- ]);
- if (! ($report['ok'] ?? false)) {
- $this->error($report['error'] ?? '分析失败');
- return self::FAILURE;
- }
- $rows = collect($report['per_question'] ?? []);
- $meta = $report['meta'] ?? [];
- $this->line('筛选条件: min_attempts='.($meta['min_attempts'] ?? '')
- .($meta['since'] ? ' since='.$meta['since'] : '')
- .($meta['student_id'] ? ' student_id='.$meta['student_id'] : '')
- .($meta['question_bank_id'] ? ' question_bank_id='.$meta['question_bank_id'] : ''));
- $constraints = $meta['calibration_constraints'] ?? [];
- if ($constraints !== []) {
- $this->line(
- '动态约束: stratified_by='.$constraints['stratified_by']
- .' min='.$constraints['min_attempts']
- .' alpha='.$constraints['alpha']
- .' max_step='.$constraints['max_step']
- .' half_life_days='.$constraints['time_decay_half_life_days']
- );
- }
- $this->line('命中题数(聚合行数): '.($meta['question_rows'] ?? $rows->count()));
- $this->newLine();
- $sort = (string) $this->option('sort');
- $rows = match ($sort) {
- 'gap_desc' => $rows->sortByDesc(fn ($r) => abs((float) ($r['calibration_gap'] ?? 0)))->values(),
- 'gap_asc' => $rows->sortBy(fn ($r) => abs((float) ($r['calibration_gap'] ?? 0)))->values(),
- default => $rows->sortByDesc('attempts')->values(),
- };
- $withStem = (bool) $this->option('with-stem');
- $stemById = [];
- if ($withStem && $rows->isNotEmpty()) {
- $ids = $rows->pluck('question_bank_id')->unique()->filter()->all();
- $stemById = DB::table('questions')
- ->whereIn('id', $ids)
- ->pluck('stem', 'id')
- ->all();
- }
- $limit = (int) $this->option('limit');
- $slice = $limit === 0 ? $rows : $rows->take($limit);
- $tableRows = $slice->map(function (array $r) use ($withStem, $stemById) {
- $norm = $r['bank_difficulty_normalized'];
- $emp = $r['empirical_error_rate'];
- $line = [
- (string) ($r['question_bank_id'] ?? ''),
- (string) ($r['question_code'] ?? ''),
- (string) ($r['attempts'] ?? ''),
- (string) ($r['correct_count'] ?? ''),
- (string) ($r['wrong_count'] ?? ''),
- $r['accuracy'] !== null ? (string) $r['accuracy'] : '',
- $r['bank_difficulty'] !== null ? (string) $r['bank_difficulty'] : '',
- $norm !== null ? (string) $norm : '',
- $r['avg_paper_question_difficulty'] !== null ? (string) round((float) $r['avg_paper_question_difficulty'], 4) : '',
- $emp !== null ? (string) round((float) $emp, 4) : '',
- $r['calibration_gap'] !== null ? (string) $r['calibration_gap'] : '',
- isset($r['calibration_weighted_error_rate']) ? (string) $r['calibration_weighted_error_rate'] : '',
- isset($r['calibration_effective_attempts']) ? (string) $r['calibration_effective_attempts'] : '',
- (string) (($r['calibration_recommendation']['action'] ?? 'hold')),
- isset($r['calibration_recommendation']['delta']) ? (string) $r['calibration_recommendation']['delta'] : '',
- isset($r['calibration_recommendation']['suggested_difficulty']) ? (string) $r['calibration_recommendation']['suggested_difficulty'] : '',
- (string) ($r['mistake_records_count'] ?? '0'),
- ];
- if ($withStem) {
- $id = (int) ($r['question_bank_id'] ?? 0);
- $raw = $stemById[$id] ?? '';
- $text = $raw !== '' ? mb_substr(trim(strip_tags($raw)), 0, 60) : '';
- $line[] = $text;
- }
- return $line;
- })->all();
- $headers = [
- '题库id',
- 'question_code',
- '作答次数',
- '对',
- '错',
- '正确率',
- '题库difficulty(原值)',
- '题库难度(0~1)',
- '卷面难度均值',
- '实测错误率',
- '标定-实测差(gap)',
- '分层时衰错误率',
- '有效样本(时衰)',
- '建议动作',
- '建议delta',
- '建议新难度',
- '错题本行数',
- ];
- if ($withStem) {
- $headers[] = '题干前60字';
- }
- $this->info('【每一道做过且已判分的题】——以下为聚合结果(同一题库 id 跨所有相关试卷合并)');
- $this->table($headers, $tableRows);
- if ($limit > 0 && $rows->count() > $limit) {
- $this->comment('仅显示前 '.$limit.' 行,共 '.$rows->count().' 行;加 --limit=0 可输出全部(建议配合 --csv)。');
- }
- $this->newLine();
- $qb = $meta['question_bank_id'] ?? null;
- if ($qb !== null) {
- $q = Question::query()->find($qb);
- if ($q) {
- $this->info('【本题题干节选】question_bank_id='.$qb);
- $this->line(mb_substr(trim(strip_tags((string) $q->stem)), 0, 400));
- $this->newLine();
- }
- }
- if ($this->option('with-aggregate')) {
- $s = $report['summary'] ?? [];
- $this->line('Pearson(题库0~1难度 vs 实测错误率): '.json_encode($s['pearson_bank_difficulty_vs_empirical_error_rate'] ?? null));
- $this->line($s['interpretation'] ?? '');
- $this->line('Pearson(学案档位 vs 单次是否错): '.json_encode($s['pearson_paper_difficulty_category_vs_incorrect'] ?? null));
- $paper = $report['paper_difficulty_category_vs_incorrect_rate'] ?? [];
- $this->table(
- ['学案档位(0-4)', '条数', '错误率'],
- collect($paper['by_category'] ?? [])->map(fn ($r) => [
- $r['difficulty_category_numeric'] ?? 'unknown',
- $r['n'],
- $r['incorrect_rate'],
- ])->all()
- );
- $this->table(
- ['bin_min', 'bin_max', '题数', '总作答', '平均正确率'],
- collect($report['bins_by_bank_difficulty'] ?? [])->map(fn ($b) => [
- $b['min'], $b['max'], $b['n_questions'], $b['total_attempts'], $b['mean_accuracy'],
- ])->all()
- );
- $this->newLine();
- }
- $jsonPath = $this->option('json');
- if ($jsonPath) {
- File::put($jsonPath, json_encode($report, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
- $this->info('JSON: '.$jsonPath);
- }
- $csvPath = $this->option('csv');
- if ($csvPath) {
- $this->writeCsv($csvPath, $report['per_question'] ?? []);
- $this->info('CSV: '.$csvPath);
- }
- return self::SUCCESS;
- }
- /**
- * @param list<array<string, mixed>> $rows
- */
- private function writeCsv(string $path, array $rows): void
- {
- $fh = fopen($path, 'wb');
- if ($fh === false) {
- $this->error('无法写入 CSV');
- return;
- }
- $headers = [
- 'question_bank_id',
- 'question_code',
- 'attempts',
- 'correct_count',
- 'wrong_count',
- 'accuracy',
- 'bank_difficulty',
- 'bank_difficulty_normalized',
- 'avg_paper_question_difficulty',
- 'empirical_error_rate',
- 'calibration_gap',
- 'calibration_weighted_error_rate',
- 'calibration_effective_attempts',
- 'recommended_action',
- 'recommended_delta',
- 'suggested_difficulty',
- 'mistake_records_count',
- ];
- fputcsv($fh, $headers);
- foreach ($rows as $r) {
- fputcsv($fh, [
- $r['question_bank_id'] ?? '',
- $r['question_code'] ?? '',
- $r['attempts'] ?? '',
- $r['correct_count'] ?? '',
- $r['wrong_count'] ?? '',
- $r['accuracy'] ?? '',
- $r['bank_difficulty'] ?? '',
- $r['bank_difficulty_normalized'] ?? '',
- $r['avg_paper_question_difficulty'] ?? '',
- $r['empirical_error_rate'] ?? '',
- $r['calibration_gap'] ?? '',
- $r['calibration_weighted_error_rate'] ?? '',
- $r['calibration_effective_attempts'] ?? '',
- $r['calibration_recommendation']['action'] ?? '',
- $r['calibration_recommendation']['delta'] ?? '',
- $r['calibration_recommendation']['suggested_difficulty'] ?? '',
- $r['mistake_records_count'] ?? '',
- ]);
- }
- fclose($fh);
- }
- }
|