MasteryCalculator.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\DB;
  4. use Illuminate\Support\Facades\Log;
  5. use Illuminate\Support\Collection;
  6. /**
  7. * 知识掌握度计算引擎(PHP版本)
  8. * 基于BKT(Bayesian Knowledge Tracing)模型和多种因素综合计算
  9. *
  10. * 参考LearningAnalytics的Python实现迁移而来
  11. */
  12. class MasteryCalculator
  13. {
  14. /**
  15. * 难度权重配置
  16. * 简单题目权重低,困难题目权重高
  17. */
  18. private const DIFFICULTY_WEIGHTS = [
  19. 0.30 => 0.8, // 简单题目权重
  20. 0.60 => 1.0, // 中等题目权重
  21. 0.85 => 1.3, // 困难题目权重
  22. ];
  23. /**
  24. * 时间效率基准值(秒)
  25. * 不同难度题目的平均完成时间
  26. */
  27. private const TIME_BASELINE = [
  28. 0.30 => 60, // 简单题平均用时
  29. 0.60 => 120, // 中等题平均用时
  30. 0.85 => 180, // 困难题平均用时
  31. ];
  32. /**
  33. * 掌握度阈值配置
  34. */
  35. private const MASTERY_THRESHOLD_WEAK = 0.50; // 薄弱点阈值
  36. private const MASTERY_THRESHOLD_GOOD = 0.70; // 良好阈值
  37. private const MASTERY_THRESHOLD_MASTER = 0.85; // 掌握阈值
  38. /**
  39. * 最小练习题目数量
  40. */
  41. private const MIN_PRACTICE_QUESTIONS = 5;
  42. /**
  43. * 最小正确率要求
  44. */
  45. private const MIN_CORRECT_RATE = 0.60;
  46. /**
  47. * 计算学生对指定知识点的掌握度
  48. *
  49. * @param string $studentId 学生ID
  50. * @param string $kpCode 知识点编码
  51. * @param array|null $attempts 答题记录(可选,默认从数据库查询)
  52. * @return array 返回['mastery' => 掌握度, 'confidence' => 置信度, 'trend' => 趋势]
  53. */
  54. public function calculateMasteryLevel(string $studentId, string $kpCode, ?array $attempts = null): array
  55. {
  56. // 如果没有提供答题记录,从数据库查询
  57. if ($attempts === null) {
  58. $attempts = $this->getStudentAttempts($studentId, $kpCode);
  59. }
  60. if (empty($attempts)) {
  61. return [
  62. 'mastery' => 0.0,
  63. 'confidence' => 0.0,
  64. 'trend' => 'insufficient',
  65. 'total_attempts' => 0,
  66. 'correct_attempts' => 0,
  67. 'accuracy_rate' => 0.0,
  68. ];
  69. }
  70. // 1. 计算基础正确率
  71. $accuracyRate = $this->calculateAccuracyRate($attempts);
  72. // 2. 计算难度加权分数
  73. $difficultyScore = $this->calculateDifficultyScore($attempts);
  74. // 3. 计算时间效率分数
  75. $timeScore = $this->calculateTimeEfficiency($attempts);
  76. // 4. 计算技能熟练度影响
  77. $skillFactor = $this->calculateSkillFactor($attempts);
  78. // 5. 应用遗忘曲线衰减
  79. $decayFactor = $this->calculateDecayFactor($attempts);
  80. // 6. 综合计算掌握度
  81. $baseMastery = $accuracyRate *
  82. $difficultyScore *
  83. $timeScore *
  84. $skillFactor *
  85. $decayFactor;
  86. // 7. 计算置信度
  87. $confidence = $this->calculateConfidence($attempts);
  88. // 8. 判断趋势
  89. $trend = $this->determineTrend($attempts);
  90. // 9. 计算统计信息
  91. $totalAttempts = count($attempts);
  92. $correctAttempts = count(array_filter($attempts, fn($a) => $a['is_correct']));
  93. Log::info('MasteryCalculator::calculateMasteryLevel', [
  94. 'student_id' => $studentId,
  95. 'kp_code' => $kpCode,
  96. 'total_attempts' => $totalAttempts,
  97. 'correct_attempts' => $correctAttempts,
  98. 'accuracy_rate' => $accuracyRate,
  99. 'difficulty_score' => $difficultyScore,
  100. 'time_score' => $timeScore,
  101. 'skill_factor' => $skillFactor,
  102. 'decay_factor' => $decayFactor,
  103. 'final_mastery' => $baseMastery,
  104. 'confidence' => $confidence,
  105. 'trend' => $trend,
  106. ]);
  107. return [
  108. 'mastery' => round($baseMastery, 4),
  109. 'confidence' => round($confidence, 4),
  110. 'trend' => $trend,
  111. 'total_attempts' => $totalAttempts,
  112. 'correct_attempts' => $correctAttempts,
  113. 'accuracy_rate' => round($accuracyRate * 100, 2),
  114. 'details' => [
  115. 'accuracy_rate' => $accuracyRate,
  116. 'difficulty_score' => $difficultyScore,
  117. 'time_score' => $timeScore,
  118. 'skill_factor' => $skillFactor,
  119. 'decay_factor' => $decayFactor,
  120. ],
  121. ];
  122. }
  123. /**
  124. * 计算正确率
  125. */
  126. private function calculateAccuracyRate(array $attempts): float
  127. {
  128. if (empty($attempts)) {
  129. return 0.0;
  130. }
  131. $totalAttempts = count($attempts);
  132. $correctAttempts = count(array_filter($attempts, fn($a) => $a['is_correct']));
  133. $partialAttempts = count(array_filter($attempts, function($a) {
  134. return isset($a['partial_score']) && floatval($a['partial_score']) > 0;
  135. }));
  136. // 部分正确按50%计算
  137. $correctScore = $correctAttempts + $partialAttempts * 0.5;
  138. $accuracy = $correctScore / $totalAttempts;
  139. return min($accuracy, 1.0);
  140. }
  141. /**
  142. * 计算难度加权分数
  143. */
  144. private function calculateDifficultyScore(array $attempts): float
  145. {
  146. if (empty($attempts)) {
  147. return 0.0;
  148. }
  149. $weightedSum = 0.0;
  150. $totalWeight = 0.0;
  151. foreach ($attempts as $attempt) {
  152. $difficulty = floatval($attempt['question_difficulty'] ?? 0.6);
  153. $weight = self::DIFFICULTY_WEIGHTS[$difficulty] ?? 1.0;
  154. $score = $attempt['is_correct'] ? 1.0 : 0.0;
  155. $weightedSum += $score * $weight;
  156. $totalWeight += $weight;
  157. }
  158. if ($totalWeight == 0) {
  159. return 0.0;
  160. }
  161. return $weightedSum / $totalWeight;
  162. }
  163. /**
  164. * 计算时间效率分数
  165. */
  166. private function calculateTimeEfficiency(array $attempts): float
  167. {
  168. if (empty($attempts)) {
  169. return 0.0;
  170. }
  171. $efficiencyScores = [];
  172. foreach ($attempts as $attempt) {
  173. $difficulty = floatval($attempt['question_difficulty'] ?? 0.6);
  174. $baseline = self::TIME_BASELINE[$difficulty] ?? 120;
  175. $actualTime = floatval($attempt['attempt_time_seconds'] ?? 120);
  176. // 时间效率:基准时间/实际用时,最大不超过1.5
  177. $efficiency = min($baseline / max($actualTime, 1), 1.5);
  178. $efficiencyScores[] = $efficiency;
  179. }
  180. // 取最近5次答题的平均效率
  181. $recentEfficiency = array_slice($efficiencyScores, -5);
  182. $avgEfficiency = array_sum($recentEfficiency) / count($recentEfficiency);
  183. return min($avgEfficiency, 1.0);
  184. }
  185. /**
  186. * 计算技能熟练度影响
  187. */
  188. private function calculateSkillFactor(array $attempts, ?array $skillProficiency = null): float
  189. {
  190. // 简化实现:如果有技能熟练度数据,使用它;否则返回1.0
  191. if ($skillProficiency && !empty($skillProficiency)) {
  192. // 计算平均技能熟练度
  193. $avgProficiency = array_sum($skillProficiency) / count($skillProficiency);
  194. // 技能熟练度加权:范围0.8-1.2
  195. return 0.8 + ($avgProficiency * 0.4);
  196. }
  197. return 1.0;
  198. }
  199. /**
  200. * 计算遗忘曲线衰减因子
  201. */
  202. private function calculateDecayFactor(array $attempts): float
  203. {
  204. if (empty($attempts)) {
  205. return 0.0;
  206. }
  207. // 获取最近一次答题时间
  208. $lastAttemptTime = null;
  209. foreach ($attempts as $attempt) {
  210. $attemptTime = strtotime($attempt['completed_at'] ?? $attempt['created_at'] ?? 'now');
  211. if ($lastAttemptTime === null || $attemptTime > $lastAttemptTime) {
  212. $lastAttemptTime = $attemptTime;
  213. }
  214. }
  215. if ($lastAttemptTime === null) {
  216. return 1.0;
  217. }
  218. // 计算距离现在的天数
  219. $daysSinceLastAttempt = (time() - $lastAttemptTime) / 86400; // 86400 = 24*60*60
  220. // 遗忘曲线:每天衰减2%,最大衰减50%
  221. $decayRate = min($daysSinceLastAttempt * 0.02, 0.5);
  222. $decayFactor = 1.0 - $decayRate;
  223. return max($decayFactor, 0.5); // 最低保持50%
  224. }
  225. /**
  226. * 计算置信度
  227. */
  228. private function calculateConfidence(array $attempts): float
  229. {
  230. if (empty($attempts)) {
  231. return 0.0;
  232. }
  233. $totalAttempts = count($attempts);
  234. // 基于答题次数的置信度:答题越多,置信度越高
  235. // 5次以下线性增长,5次以上增长放缓
  236. if ($totalAttempts < 5) {
  237. $baseConfidence = $totalAttempts / 5.0;
  238. } else {
  239. $baseConfidence = 0.5 + (1.0 - 0.5) * (1 - exp(-($totalAttempts - 5) / 10));
  240. }
  241. // 正确率也影响置信度
  242. $accuracyRate = $this->calculateAccuracyRate($attempts);
  243. $accuracyFactor = 0.5 + $accuracyRate * 0.5;
  244. // 综合置信度
  245. $confidence = $baseConfidence * $accuracyFactor;
  246. return min($confidence, 1.0);
  247. }
  248. /**
  249. * 判断学习趋势
  250. */
  251. private function determineTrend(array $attempts): string
  252. {
  253. if (count($attempts) < 3) {
  254. return 'insufficient'; // 数据不足
  255. }
  256. // 按时间排序
  257. usort($attempts, function($a, $b) {
  258. $timeA = strtotime($a['completed_at'] ?? $a['created_at'] ?? 0);
  259. $timeB = strtotime($b['completed_at'] ?? $b['created_at'] ?? 0);
  260. return $timeA <=> $timeB;
  261. });
  262. // 分为前后两部分
  263. $midPoint = intdiv(count($attempts), 2);
  264. $firstHalf = array_slice($attempts, 0, $midPoint);
  265. $secondHalf = array_slice($attempts, $midPoint);
  266. $firstHalfAccuracy = $this->calculateAccuracyRate($firstHalf);
  267. $secondHalfAccuracy = $this->calculateAccuracyRate($secondHalf);
  268. $improvement = $secondHalfAccuracy - $firstHalfAccuracy;
  269. if ($improvement > 0.1) {
  270. return 'improving'; // 提升
  271. } elseif ($improvement < -0.1) {
  272. return 'declining'; // 下降
  273. } else {
  274. return 'stable'; // 稳定
  275. }
  276. }
  277. /**
  278. * 获取学生的答题记录
  279. */
  280. private function getStudentAttempts(string $studentId, string $kpCode): array
  281. {
  282. $attempts = DB::table('student_attempts')
  283. ->where('student_id', $studentId)
  284. ->where('kp_code', $kpCode)
  285. ->orderBy('created_at', 'asc')
  286. ->get();
  287. return $attempts->map(function ($item) {
  288. return (array) $item;
  289. })->toArray();
  290. }
  291. /**
  292. * 批量更新学生掌握度
  293. */
  294. public function batchUpdateMastery(string $studentId, array $kpCodes): array
  295. {
  296. $results = [];
  297. foreach ($kpCodes as $kpCode) {
  298. $masteryData = $this->calculateMasteryLevel($studentId, $kpCode);
  299. // 保存到数据库
  300. DB::table('student_knowledge_mastery')
  301. ->updateOrInsert(
  302. ['student_id' => $studentId, 'kp_code' => $kpCode],
  303. [
  304. 'mastery_level' => $masteryData['mastery'],
  305. 'confidence_level' => $masteryData['confidence'],
  306. 'total_attempts' => $masteryData['total_attempts'],
  307. 'correct_attempts' => $masteryData['correct_attempts'],
  308. 'mastery_trend' => $masteryData['trend'],
  309. 'last_mastery_update' => now(),
  310. 'updated_at' => now(),
  311. ]
  312. );
  313. $results[$kpCode] = $masteryData;
  314. }
  315. return $results;
  316. }
  317. /**
  318. * 获取学生所有知识点的掌握度概览
  319. */
  320. public function getStudentMasteryOverview(string $studentId): array
  321. {
  322. $masteryList = DB::table('student_knowledge_mastery')
  323. ->where('student_id', $studentId)
  324. ->get();
  325. if ($masteryList->isEmpty()) {
  326. return [
  327. 'total_knowledge_points' => 0,
  328. 'average_mastery_level' => 0.0,
  329. 'mastered_knowledge_points' => 0,
  330. 'good_knowledge_points' => 0,
  331. 'weak_knowledge_points' => 0,
  332. 'weak_knowledge_points_list' => [],
  333. 'details' => [],
  334. ];
  335. }
  336. $masteryArray = $masteryList->toArray();
  337. $total = count($masteryArray);
  338. $average = $masteryArray ? array_sum(array_column($masteryArray, 'mastery_level')) / $total : 0;
  339. $mastered = [];
  340. $good = [];
  341. $weak = [];
  342. foreach ($masteryArray as $item) {
  343. $level = floatval($item->mastery_level);
  344. if ($level >= self::MASTERY_THRESHOLD_MASTER) {
  345. $mastered[] = $item;
  346. } elseif ($level >= self::MASTERY_THRESHOLD_GOOD) {
  347. $good[] = $item;
  348. } else {
  349. $weak[] = $item;
  350. }
  351. }
  352. return [
  353. 'total_knowledge_points' => $total,
  354. 'average_mastery_level' => round($average, 4),
  355. 'mastered_knowledge_points' => count($mastered),
  356. 'good_knowledge_points' => count($good),
  357. 'weak_knowledge_points' => count($weak),
  358. 'weak_knowledge_points_list' => $weak,
  359. 'details' => $masteryArray,
  360. ];
  361. }
  362. }