[], 'models' => [], 'reviews' => []]; } $patientId = self::identifier($row['patient_id'] ?? null); if ($patientId !== null) { $events[$eventId]['patients'][$patientId] = true; } $model = $row['model_key'] ?? null; if (is_string($model) && in_array($model, self::MODELS, true)) { $normalized = self::normalizeModel($row); $fingerprint = self::fingerprint($normalized); $events[$eventId]['models'][$model][$fingerprint] = $normalized; } if (isset($row['review']) && is_array($row['review']) && $row['review'] !== []) { $review = self::normalizeReview($row['review']); $events[$eventId]['reviews'][self::fingerprint($review)] = $review; } } $total = count($events); $models = []; foreach (self::MODELS as $model) { $models[$model] = [ 'denominator' => $total, 'valid_count' => 0, 'excluded_count' => 0, 'coverage_percent' => null, 'mean' => null, 'median' => null, 'distribution' => self::distribution([]), 'exclusion_reasons' => [], 'strata' => [], ]; } $patients = []; $unknownPatients = 0; $pairedStrata = []; $pairedCount = 0; $reviewRecords = []; foreach ($events as $eventId => $event) { $identityConflict = count($event['patients']) > 1; if (count($event['patients']) === 1) { $patientId = (string) array_key_first($event['patients']); $patients[$patientId] = ($patients[$patientId] ?? 0) + 1; } else { $unknownPatients++; } $valid = []; foreach (self::MODELS as $model) { $candidates = $event['models'][$model] ?? []; $record = count($candidates) === 1 ? reset($candidates) : null; if ($identityConflict) { $reason = 'event_patient_conflict'; } elseif (count($candidates) > 1) { $reason = 'duplicate_baseline_conflict'; } elseif ($record === null) { $reason = 'missing_result'; } else { $reason = self::exclusionReason($record); } if ($reason !== '') { $models[$model]['excluded_count']++; self::increment($models[$model]['exclusion_reasons'], $reason); continue; } $valid[$model] = $record; $models[$model]['valid_count']++; $stratumKey = self::fingerprint($record['versions']); if (!isset($models[$model]['strata'][$stratumKey])) { $models[$model]['strata'][$stratumKey] = ['versions' => $record['versions'], 'scores' => []]; } $models[$model]['strata'][$stratumKey]['scores'][] = $record['score']; } if (count($valid) === 2) { $pairedCount++; $pairKey = self::fingerprint([$valid['qwen']['versions'], $valid['openai']['versions']]); if (!isset($pairedStrata[$pairKey])) { $pairedStrata[$pairKey] = [ 'qwen_versions' => $valid['qwen']['versions'], 'openai_versions' => $valid['openai']['versions'], 'qwen_scores' => [], 'openai_scores' => [], ]; } $pairedStrata[$pairKey]['qwen_scores'][] = $valid['qwen']['score']; $pairedStrata[$pairKey]['openai_scores'][] = $valid['openai']['score']; } if ($identityConflict || count($event['reviews']) > 1) { $reviewRecords[] = ['status' => 'conflict']; } elseif ($event['reviews'] !== []) { $reviewRecords[] = reset($event['reviews']); } } foreach ($models as &$model) { $allScores = []; ksort($model['strata'], SORT_STRING); foreach ($model['strata'] as &$stratum) { $allScores = array_merge($allScores, $stratum['scores']); $summary = self::scoreSummary($stratum['scores']); unset($stratum['scores']); $stratum += $summary; } unset($stratum); $model['strata'] = array_values($model['strata']); $model['coverage_percent'] = $total > 0 ? 100.0 * $model['valid_count'] / $total : null; $model['distribution'] = self::distribution($allScores); $model['aggregation_status'] = count($model['strata']) > 1 ? 'stratified_versions' : 'single_version'; $model['sample_status'] = $model['valid_count'] < 10 ? 'insufficient_sample' : 'descriptive_only'; if (count($model['strata']) === 1) { $model['mean'] = $model['strata'][0]['mean']; $model['median'] = $model['strata'][0]['median']; } elseif ($model['strata'] === []) { $model['aggregation_status'] = 'no_valid_samples'; } ksort($model['exclusion_reasons'], SORT_STRING); } unset($model); ksort($pairedStrata, SORT_STRING); foreach ($pairedStrata as &$stratum) { $stratum['count'] = count($stratum['qwen_scores']); $stratum['qwen'] = self::scoreSummary($stratum['qwen_scores']); $stratum['openai'] = self::scoreSummary($stratum['openai_scores']); unset($stratum['qwen_scores'], $stratum['openai_scores']); } unset($stratum); return [ 'metric' => '药味与剂量一致度', 'total_events' => $total, 'patient_count' => count($patients), 'unknown_patient_events' => $unknownPatients, 'repeated_patient_events' => array_sum($patients) - count($patients), 'invalid_row_count' => $invalidRows, 'models' => $models, 'paired_count' => $pairedCount, 'paired_strata' => array_values($pairedStrata), 'reviews' => self::reviewSummary($reviewRecords, $total), ]; } private static function normalizeModel(array $row): array { $comparison = isset($row['comparison']) && is_array($row['comparison']) ? $row['comparison'] : []; $score = $comparison['score'] ?? null; $score = (is_float($score) || is_int($score) || is_string($score)) && is_numeric($score) && is_finite((float) $score) ? (float) $score : null; return [ 'baseline_eligible' => ($row['baseline_eligible'] ?? false) === true, 'exclusion_reason' => self::string($row['exclusion_reason'] ?? null), 'status' => self::string($comparison['status'] ?? null), 'reason_code' => self::string($comparison['reason_code'] ?? null), 'score' => $score, 'versions' => [ 'model_version' => self::string($row['model_version'] ?? null), 'prompt_version' => self::string($row['prompt_version'] ?? null), 'algorithm_version' => self::string($comparison['algorithm_version'] ?? null), 'dictionary_version' => self::string($row['dictionary_version'] ?? null), ], ]; } private static function exclusionReason(array $record): string { if (!$record['baseline_eligible']) { return $record['exclusion_reason'] !== '' ? $record['exclusion_reason'] : 'baseline_ineligible'; } if ($record['exclusion_reason'] !== '') { return $record['exclusion_reason']; } if ($record['status'] !== 'comparable') { return $record['reason_code'] !== '' ? $record['reason_code'] : 'not_comparable'; } if ($record['score'] === null || $record['score'] < 0.0 || $record['score'] > 100.0) { return 'invalid_score'; } if ($record['versions']['algorithm_version'] === '') { return 'missing_algorithm_version'; } return ''; } private static function scoreSummary(array $scores): array { sort($scores, SORT_NUMERIC); $count = count($scores); $middle = intdiv($count, 2); return [ 'count' => $count, 'mean' => $count > 0 ? array_sum($scores) / $count : null, 'median' => $count === 0 ? null : ($count % 2 === 1 ? $scores[$middle] : ($scores[$middle - 1] + $scores[$middle]) / 2.0), 'distribution' => self::distribution($scores), 'sample_status' => $count < 10 ? 'insufficient_sample' : 'descriptive_only', ]; } private static function distribution(array $scores): array { $bins = ['[0,20)' => 0, '[20,40)' => 0, '[40,60)' => 0, '[60,80)' => 0, '[80,100]' => 0]; $keys = array_keys($bins); foreach ($scores as $score) { $bins[$keys[min(4, (int) floor($score / 20.0))]]++; } return $bins; } private static function normalizeReview(array $review): array { return [ 'status' => self::string($review['status'] ?? null), 'independent' => ($review['independent'] ?? false) === true, 'outcome' => self::string($review['outcome'] ?? null), 'sampling_method' => self::string($review['sampling_method'] ?? null), 'disputed' => ($review['disputed'] ?? false) === true, ]; } private static function reviewSummary(array $reviews, int $total): array { $result = [ 'status' => $reviews === [] ? 'no_samples' : 'recorded', 'reviewed_events' => count($reviews), 'unreviewed_events' => $total - count($reviews), 'sampling_coverage_percent' => $total > 0 ? 100.0 * count($reviews) / $total : null, 'evaluable_count' => 0, 'qualified_count' => 0, 'qualification_rate' => null, 'exclusion_reasons' => [], 'sampling_groups' => [], 'confidence_interval' => null, 'confidence_interval_reason' => '未指定抽样及重复患者相关性的统计方案', ]; foreach ($reviews as $review) { if ($review['status'] === 'conflict') { self::increment($result['exclusion_reasons'], 'review_conflict'); } elseif ($review['status'] !== 'completed') { self::increment($result['exclusion_reasons'], 'review_not_completed'); } elseif (!$review['independent']) { self::increment($result['exclusion_reasons'], 'review_not_independent'); } elseif ($review['disputed']) { self::increment($result['exclusion_reasons'], 'review_disputed'); } elseif ($review['outcome'] === 'not_evaluable') { self::increment($result['exclusion_reasons'], 'review_not_evaluable'); } elseif (!in_array($review['outcome'], ['qualified', 'needs_revision', 'unqualified'], true)) { self::increment($result['exclusion_reasons'], 'invalid_review_outcome'); } elseif (!in_array($review['sampling_method'], ['random', 'stratified', 'risk_directed'], true)) { self::increment($result['exclusion_reasons'], 'unknown_review_sampling'); } else { $method = $review['sampling_method']; if (!isset($result['sampling_groups'][$method])) { $result['sampling_groups'][$method] = [ 'sampling_method' => $method, 'evaluable_count' => 0, 'qualified_count' => 0, 'outcomes' => ['qualified' => 0, 'needs_revision' => 0, 'unqualified' => 0], ]; } $group = &$result['sampling_groups'][$method]; $group['evaluable_count']++; $group['outcomes'][$review['outcome']]++; $result['evaluable_count']++; if ($review['outcome'] === 'qualified') { $group['qualified_count']++; $result['qualified_count']++; } unset($group); } } ksort($result['sampling_groups'], SORT_STRING); foreach ($result['sampling_groups'] as &$group) { $group['qualification_rate'] = 100.0 * $group['qualified_count'] / $group['evaluable_count']; $group['sample_status'] = $group['evaluable_count'] < 10 ? 'insufficient_sample' : 'descriptive_only'; } unset($group); $result['sampling_groups'] = array_values($result['sampling_groups']); if (count($result['sampling_groups']) === 1) { $result['qualification_rate'] = $result['sampling_groups'][0]['qualification_rate']; } elseif (count($result['sampling_groups']) > 1) { $result['status'] = 'stratified_sampling'; } ksort($result['exclusion_reasons'], SORT_STRING); return $result; } private static function identifier($value): ?string { if (is_int($value)) { return $value > 0 ? (string) $value : null; } if (!is_string($value) || preg_match('//u', $value) !== 1) { return null; } $value = trim($value); return $value !== '' && $value !== '0' ? $value : null; } private static function string($value): string { return is_string($value) && preg_match('//u', $value) === 1 ? trim($value) : ''; } private static function fingerprint(array $value): string { return hash('sha256', json_encode($value, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); } private static function increment(array &$counts, string $key): void { $counts[$key] = ($counts[$key] ?? 0) + 1; } }