347 lines
16 KiB
PHP
347 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\prescriptionai;
|
|
|
|
/**
|
|
* Pure summaries of already-authorized, in-scope first-submission events.
|
|
*
|
|
* Input is a flat list, normally one row per event and model:
|
|
* {event_id: positive int|string, patient_id?: positive int|string, doctor_id?: int,
|
|
* model_key: 'qwen'|'openai', baseline_eligible: bool,
|
|
* exclusion_reason?: string, comparison?: {status, score, algorithm_version},
|
|
* model_version?: string, prompt_version?: string, dictionary_version?: string,
|
|
* review?: {status:'completed', independent:true,
|
|
* outcome:'qualified'|'needs_revision'|'unqualified'|'not_evaluable',
|
|
* sampling_method:'random'|'stratified'|'risk_directed', disputed?:bool}}.
|
|
*
|
|
* Caller establishes baseline_eligible from independent candidate freezing,
|
|
* decision-time evidence, first human submission and absence of prior AI advice.
|
|
* This function never infers these facts from a score or successful HTTP status.
|
|
* Include a row with only event_id/patient_id when neither model has a result.
|
|
* Missing model results remain in the shared event denominator. Supply the frozen
|
|
* baseline, not current/latest results. Conflicting duplicates are excluded rather
|
|
* than choosing the largest score or treating a revision as another sample.
|
|
*
|
|
* Output models.{qwen,openai} includes valid_count/excluded_count/coverage_percent,
|
|
* mean/median/distribution/exclusion_reasons/strata. Means and medians use original
|
|
* unrounded scores. With multiple version strata the top-level mean/median are null;
|
|
* individual strata remain available. No model-averaged or medical accuracy score.
|
|
* Review rates are descriptive and separated by sampling method; no invented review
|
|
* data or independence assumption for confidence intervals/repeated patients.
|
|
*/
|
|
final class PrescriptionAiStatistics
|
|
{
|
|
private const MODELS = ['qwen', 'openai'];
|
|
|
|
public static function summarize(array $rows): array
|
|
{
|
|
$events = [];
|
|
$invalidRows = 0;
|
|
foreach ($rows as $row) {
|
|
if (!is_array($row) || self::identifier($row['event_id'] ?? null) === null) {
|
|
$invalidRows++;
|
|
continue;
|
|
}
|
|
$eventId = self::identifier($row['event_id']);
|
|
if (!isset($events[$eventId])) {
|
|
$events[$eventId] = ['patients' => [], '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;
|
|
}
|
|
}
|