Files
zyt/server/app/common/service/prescriptionai/PrescriptionAiStatistics.php
T
2026-09-10 15:19:17 +08:00

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;
}
}