bone_2026/docs/pitch.html

612 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Материалы к презентации — контроль качества DXA</title>
<!--
Страница самодостаточна: CSS и JS внутри, внешних ресурсов нет.
Требование методики — работа без обращения к внешним сервисам, поэтому
страницу можно открывать в закрытом контуре и печатать.
-->
<style>
:root {
--fg: #1f2933;
--muted: #616e7c;
--line: #e4e7eb;
--accent: #1d6fb8;
--accent-soft: #eef6fd;
--warn: #8a5a00;
--warn-soft: #fdf6e3;
--ok: #0f6b3f;
--ok-soft: #eefaf3;
--code-bg: #f5f7fa;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: #f7f8fa;
color: var(--fg);
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.wrap { max-width: 1060px; margin: 0 auto; padding: 28px 20px 80px; }
header.page {
background: #fff; border: 1px solid var(--line); border-radius: 12px;
padding: 22px 26px; margin-bottom: 20px;
}
header.page h1 { margin: 0 0 6px; font-size: 26px; line-height: 1.25; }
header.page p { margin: 0; color: var(--muted); }
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 20px 0 18px; }
.tabs button {
font: inherit; cursor: pointer; border: 1px solid var(--line);
background: #fff; color: var(--fg); padding: 10px 16px; border-radius: 999px;
}
.tabs button[aria-selected="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
.tabs button:hover:not([aria-selected="true"]) { background: var(--accent-soft); }
.panel { display: none; }
.panel.active { display: block; }
section.card {
background: #fff; border: 1px solid var(--line); border-radius: 12px;
padding: 22px 26px; margin-bottom: 18px;
}
h2 { font-size: 22px; margin: 0 0 4px; }
h2 .sub { display: block; font-size: 14px; font-weight: 400; color: var(--muted); margin-top: 4px; }
h3 { font-size: 17px; margin: 26px 0 8px; }
h3:first-of-type { margin-top: 12px; }
h4 { font-size: 15px; margin: 18px 0 6px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
p, li { margin: 8px 0; }
ul, ol { padding-left: 22px; }
.say {
background: var(--accent-soft); border-left: 4px solid var(--accent);
padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 12px 0;
}
.say strong { display: block; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--accent); margin-bottom: 4px; }
.warn { background: var(--warn-soft); border-left: 4px solid var(--warn); padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 12px 0; }
.ok { background: var(--ok-soft); border-left: 4px solid var(--ok); padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 12px 0; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 15px; }
th, td { border: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; }
th { background: #f2f5f8; font-weight: 600; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 14px; }
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; overflow-x: auto; }
pre code { background: none; padding: 0; }
dl.defs { margin: 0; }
dl.defs dt { font-weight: 600; margin-top: 16px; }
dl.defs dd { margin: 4px 0 0; color: #33414e; }
dl.defs dd .why { display: block; color: var(--muted); font-size: 14px; margin-top: 3px; }
.tag { display: inline-block; font-size: 12px; padding: 1px 7px; border-radius: 999px; background: #eef1f4; color: #4a5560; margin-left: 6px; vertical-align: middle; }
.tag.table { background: #e8f4ea; color: #23633a; }
.tag.cond { background: #fdf1e3; color: #8a5a00; }
.src { font-size: 13px; color: var(--muted); }
footer.page { color: var(--muted); font-size: 14px; text-align: center; margin-top: 30px; }
@media print {
body { background: #fff; }
.tabs { display: none; }
.panel { display: block !important; page-break-before: always; }
.panel:first-of-type { page-break-before: avoid; }
section.card { border: none; padding: 0; margin-bottom: 24px; }
.wrap { max-width: none; padding: 0; }
}
</style>
<noscript>
<style>.tabs { display: none; } .panel { display: block !important; }</style>
</noscript>
</head>
<body>
<div class="wrap">
<header class="page">
<h1>Контроль качества денситометрических исследований (DXA)</h1>
<p>Материалы к презентации в трёх вариантах: краткий спич, словарь терминов к нему и полная техническая выкладка. Все числа проверяемы, источник указан рядом.</p>
</header>
<div class="tabs" role="tablist">
<button role="tab" aria-selected="true" data-panel="p1">1. Краткий спич</button>
<button role="tab" aria-selected="false" data-panel="p2">2. Определения</button>
<button role="tab" aria-selected="false" data-panel="p3">3. Техническая выкладка</button>
</div>
<!-- ============================ ВАРИАНТ 1 ============================ -->
<div class="panel active" id="p1">
<section class="card">
<h2>Краткий спич
<span class="sub">Около трёх минут. Каждый блок — один абзац для произнесения; ниже — что показать на слайде.</span>
</h2>
<h3>1. Задача</h3>
<div class="say"><strong>Произнести</strong>
Мы делали цифрового помощника по контролю качества денситометрии. По снимку DXA нужно решить, пригоден ли он для дальнейшего анализа, и объяснить, что именно не так. Вход — DICOM, выход — таблица XLSX или CSV, одна строка на снимок. Работает офлайн, в контейнере, не дольше трёх минут на исследование. Области — поясничный отдел позвоночника и проксимальный отдел бедра.
</div>
<p class="src">На слайд: колонки результата и требования — офлайн, контейнер, три минуты.</p>
<h3>2. Данные</h3>
<div class="say"><strong>Произнести</strong>
В наборе 544 файла, но это 252 уникальных снимка в ста исследованиях: остальное — те же кадры, сохранённые повторно. Экспертная оценка сделана в таблице, по каждому исследованию и с разбивкой на критерии: укладка, ось, артефакты, позиционирование, область интереса.
</div>
<h3>3. Как из оценки исследования получилась метка снимка</h3>
<div class="say"><strong>Произнести</strong>
Оценка в таблице относится к исследованию, а модель работает со снимками — вердикт нужно было перенести. Здесь помогло свойство набора: после склейки дублей каждая анатомическая область встречается в исследовании ровно один раз. Значит, не нужно угадывать, какой из снимков «плохой»: вердикт области переносится на её снимок однозначно. Так размечены все 252 снимка, из них 77 с нарушениями.
</div>
<p class="src">На слайд: 544 файла → 252 снимка → 100 исследований; одна область на исследование.</p>
<h3>4. Выбор правила разметки делали измерением</h3>
<div class="say"><strong>Произнести</strong>
Метку можно было строить только из экспертной таблицы или дополнительно учитывать служебные пометки, которые проставлялись при подготовке набора. Они расходились с оценкой эксперта в пятнадцати случаях из двухсот пятидесяти двух, поэтому мы не стали верить на слово ни одному варианту: зафиксировали разбиение, обучили оба пятью seed'ами и сравнили по одному эталону. Победило правило «только таблица»: ROC-AUC 0.68 против 0.62, преимущество на всех пяти seed'ах. Служебные пометки в метках не участвуют.
</div>
<h3>5. Модель</h3>
<div class="say"><strong>Произнести</strong>
Архитектура намеренно простая: ResNet18 с весами ImageNet как замороженный экстрактор признаков и линейная голова. Полное дообучение на двухстах снимках переобучается — train-метрика уходит в единицу, а качество на валидации падает до случайного. Аугментацию отключили: яркость и положение снимка сами являются признаками качества, и её включение роняло площадь под ROC-кривой с 0.87 до 0.56. Порог решения подбираем по логиту, максимизируя F1: вероятности насыщаются, и порог 0.5 даёт почти нулевой recall.
</div>
<h3>6. Результат</h3>
<div class="say"><strong>Произнести</strong>
На фиксированном разбиении, пять seed'ов, оценка по вердикту эксперта: ROC-AUC 0.676 с интервалом от 0.63 до 0.72, PR-AUC 0.476, F1 0.568. Рабочий чекпоинт — обычный прогон с seed по умолчанию, его собственная оценка 0.671, то есть близка к среднему: результат не отобран по удачности.
</div>
<p class="src">На слайд: три строки метрик с интервалами и одна оговорка про эталон.</p>
<h3>7. Модель смотрит на снимок, а не на область</h3>
<div class="say"><strong>Произнести</strong>
Область исследования почти однозначно определяется шириной кадра, поэтому есть риск, что модель просто угадывает анатомию. Мы это проверили отдельно: внутри областей модель даёт AUC от 0.85 до 0.93, а правило «позвоночник — значит нарушение» — ровно 0.5, потому что внутри области подсказки нет. Значит, модель работает с содержимым снимка.
</div>
<h3>8. Скорость</h3>
<div class="say"><strong>Произнести</strong>
Обработка одного снимка — около пятнадцати миллисекунд, на процессоре двадцать. При бюджете три минуты на исследование запас более чем тысячекратный. Чекпоинт занимает 43 мегабайта, ускоритель для этой задачи не обязателен: время уходит на декодирование снимка, а не на сеть.
</div>
<h3>9. Чего мы не утверждаем</h3>
<div class="say"><strong>Произнести</strong>
Модель бинарная: она не определяет тип нарушения. Тип, который вы видите в интерфейсе, посчитан эвристикой по метрикам снимка и помечен соответствующей пометкой — мы намеренно не выдаём предположение за заключение. Эталон, по которому мы мерили, — та же экспертная таблица, независимой истины у нас нет. Данных мало: 252 снимка, в валидации шестнадцать нарушений, интервалы широкие. И отдельно: локальная vision-модель на девять миллиардов параметров, которой мы хотели размечать снимки визуально, вынесла «непригоден» всем четырнадцати снимкам калибровки, включая заведомо качественные. От этого пути отказались, проверив его одним прогоном, а не неделей разработки.
</div>
<h3>10. Что дальше</h3>
<div class="say"><strong>Произнести</strong>
Три направления: поштучная разметка снимков специалистом, чтобы снять главное ограничение; мультилейбл-модель для типов нарушений вместо эвристики; больше исследований, чтобы сузить интервалы.
</div>
</section>
<section class="card">
<h2>Шпаргалка: цифры, которые будут спрашивать</h2>
<table>
<thead><tr><th>Вопрос</th><th>Ответ</th><th>Откуда</th></tr></thead>
<tbody>
<tr><td>Сколько снимков?</td><td>252 уникальных в 100 исследованиях (544 файла на диске)</td><td class="src">склейка дублей по пикселям</td></tr>
<tr><td>Сколько нарушений?</td><td>77 из 252 (30.6 %): 74 по экспертной таблице + 3 без экспертной оценки</td><td class="src">labels/labels_images.csv</td></tr>
<tr><td>ROC-AUC</td><td>0.6764 [0.6309, 0.7218], 5 seed'ов</td><td class="src">models/compare_rules/rule_comparison.md</td></tr>
<tr><td>PR-AUC / F1</td><td>0.4759 [0.4141, 0.5377] / 0.5676 [0.5270, 0.6082]</td><td class="src">там же</td></tr>
<tr><td>Почему такое правило разметки?</td><td>замер: +0.0564 ROC-AUC против варианта со служебными пометками, 5 из 5 seed'ов</td><td class="src">там же</td></tr>
<tr><td>Вклад содержимого снимка</td><td>внутри областей AUC 0.85–0.93 против 0.50 у правила области</td><td class="src">discriminator</td></tr>
<tr><td>Скорость</td><td>≈15 мс на снимок на ускорителе, 20 мс на CPU</td><td class="src">замер на 544 файлах</td></tr>
<tr><td>Размер модели</td><td>42.8 МБ, 11.2 млн параметров, обучается только голова</td><td class="src">models/dxa_model.pth</td></tr>
<tr><td>Тесты</td><td>208</td><td class="src">./run.sh test</td></tr>
</tbody>
</table>
</section>
</div>
<!-- ============================ ВАРИАНТ 2 ============================ -->
<div class="panel" id="p2">
<section class="card">
<h2>Определения
<span class="sub">Расширение краткого спича: термины, которыми придётся отвечать на вопросы. Формулировки привязаны к тому, как эти слова используются в решении.</span>
</h2>
<h4>Данные и формат</h4>
<dl class="defs">
<dt>DXA / ДРА <span class="tag">двухэнергетическая рентгеновская абсорбциометрия</span></dt>
<dd>Метод измерения минеральной плотности костной ткани. От качества укладки и разметки зависит не «красивость» снимка, а само число.
<span class="why">Задача — контроль качества измерения, а не диагностика перелома.</span></dd>
<dt>DICOM</dt>
<dd>Стандарт хранения и передачи медицинских изображений: пиксельные данные плюс метаданные.
<span class="why">Вход решения; из метаданных берём идентификаторы, но решение не зависит от персональных данных.</span></dd>
<dt>StudyInstanceUID и SOPInstanceUID</dt>
<dd><code>StudyInstanceUID</code> идентифицирует исследование, <code>SOPInstanceUID</code> — конкретный снимок. Оба идут в выходной файл: <code>study_uid</code> и <code>image_uid</code>.
<span class="why">Подводный камень: имя каталога исследования в наборе не совпадает с этим тегом, а экспертная таблица ссылается на каталог. Склейка идёт по каталогу, в отчёт попадает тег.</span></dd>
<dt>ROI <span class="tag">region of interest</span></dt>
<dd>Область интереса: рамка или контур, по которой считают плотность. Правильность её проведения — самостоятельный критерий качества.
<span class="why">В предоставленных DICOM разметка ROI отсутствует, сравнить её не с чем: корректность областей оценивается экспертом в таблице, и это открытое ограничение решения.</span></dd>
<dt>Побайтный дубль</dt>
<dd>Файлы с идентичным пиксельным содержимым под разными именами. В наборе 544 файла против 252 уникальных снимков.
<span class="why">Зачем склеивать: иначе один и тот же снимок попадал и в обучение, и в валидацию.</span></dd>
<dt>Анатомическая область</dt>
<dd>В решении три области: <code>spine</code> (поясничный отдел), <code>hip_right</code> и <code>hip_left</code> (проксимальный отдел бедра).
<span class="why">Область определяется по геометрии кадра, а не по метаданным: ширина кадра у позвоночника около 300 пикселей, у бедра около 280. Порог привязан к текущему оборудованию — это ограничение.</span></dd>
</dl>
<h4>Разметка</h4>
<dl class="defs">
<dt>Экспертная таблица</dt>
<dd>Файл <code>разметка.xlsx</code>: по каждому исследованию отмечены критерии — укладка, ось, артефакты для позвоночника; позиционирование и область интереса для каждого бедра; плюс итог по области и комментарий.
<span class="why">Значение 1 в критерии означает нарушение, хотя часть заголовков сформулирована положительно. Проверено: итог области равен логическому ИЛИ критериев (бёдра 72/72 и 78/78, позвоночник 96/99).</span></dd>
<dt>Единица разметки</dt>
<dd>То, к чему относится метка. Таблица описывает исследование, решение работает со снимками.
<span class="why">Ключевое свойство набора: после склейки дублей каждая область встречается в исследовании ровно один раз, поэтому вердикт переносится на снимок области однозначно.</span></dd>
<dt>Правило метки <span class="tag">table / union</span></dt>
<dd>Как из оценки получается метка снимка. <code>table</code> — только экспертная таблица (принято), <code>union</code> — таблица и служебные пометки, проставленные при подготовке набора.
<span class="why">Выбрано измерением: <code>table</code> дал ROC-AUC 0.6764 против 0.6199, преимущество на всех пяти seed'ах. Есть третье правило, <code>expert</code>: только снимки с экспертной оценкой — оно служит эталоном при оценке, а не для обучения.</span></dd>
<dt>quality_class</dt>
<dd>Бинарный класс: 0 — качественное изображение, 1 — есть нарушение. Это итоговое решение модели.</dd>
<dt>Пригоден / непригоден</dt>
<dd>Формулировка того же решения словами. «Пригоден» означает, что нет видимых причин мешать специалисту выполнить дальнейший анализ; это не значит, что снимок диагностически нормален.
<span class="why">Правило методики: если хотя бы один существенный пункт нарушен или не подтверждается по изображению, снимок непригоден. Сомнительные случаи не пропускаются.</span></dd>
<dt>violation_type</dt>
<dd>Тип нарушения — строка, перечень через точку с запятой, словарь канонический и единый для решения, отчёта DICOM SR и интерфейса.
<span class="why">Пять кодов кодирует экспертная таблица: positioning, axis_deviation, artifact, rotation, roi_incorrect. Ещё три задаёт методика: motion, incomplete_anatomy, labeling_error. Плюс unspecified — нарушение без уточнения.</span></dd>
<dt>Эвристика</dt>
<dd>Правило, а не обученная модель. Тип нарушения определяется эвристикой по метрикам снимка, потому что модель бинарная.
<span class="why">В интерфейсе это помечено пометкой «эвристика» с пояснением: показывать предположение как заключение нельзя.</span></dd>
<dt>Зеркалирование стороны бедра</dt>
<dd>Ситуация, когда в исследовании снят один снимок бедра, а в таблице заполнен столбец противоположной стороны — 6 исследований из 7 с одним бедром.
<span class="why">Логика: снимок один и заполненный столбец один — они соответствуют друг другу. Факт переноса фиксируется флагом; теги латеральности в DICOM пусты, поэтому сторону иначе не проверить.</span></dd>
</dl>
<h4>Модель и обучение</h4>
<dl class="defs">
<dt>Линейный зонд <span class="tag">linear probe</span></dt>
<dd>Режим, при котором свёрточная часть сети заморожена и обучается только линейный слой поверх её признаков.
<span class="why">На двухстах снимках полное дообучение переобучается: train F1 уходит в единицу при случайном AUC на валидации. Обучаемых параметров остаются тысячи вместо миллионов.</span></dd>
<dt>Frozen backbone и BatchNorm</dt>
<dd>При заморозке отключается не только градиент, но и train-режим слоёв нормализации: иначе бегущие статистики продолжают меняться на обучающих батчах и входной слой получает не те данные, на которых калибровался.</dd>
<dt>Препроцессинг</dt>
<dd>Один путь для обучения и API: прочитать DICOM с учётом наклона и инверсии, привести к диапазону по перцентилям 0.5–99.5, увеличить до 224×224, повторить в три канала, нормировать по статистикам ImageNet.
<span class="why">Перцентили вместо минимума-максимума: одиночные яркие пиксели (металл, метка оператора) иначе сжимают весь диапазон.</span></dd>
<dt>Порог по логиту</dt>
<dd>Решающее правило сравнивает логит с порогом, который подобран по F1 на валидации и хранится в чекпоинте вместе с весами (у рабочего — логит −0.493, вероятность 0.379).
<span class="why">Почему не 0.5: вероятности насыщаются, и фиксированный порог давал почти нулевой recall при доле нарушений около 30 %.</span></dd>
<dt>Аугментация</dt>
<dd>Случайные искажения при обучении. Здесь отключена по умолчанию.
<span class="why">Причина содержательная: яркость и положение снимка сами являются признаками качества, поэтому искажать их — значит уничтожать целевую информацию. Проверено: включение роняло AUC с 0.87 до 0.56.</span></dd>
<dt>Разбиение по исследованиям</dt>
<dd>Все снимки одного исследования попадают только в одну часть — обучение или валидацию.
<span class="why">Иначе получается утечка: снимки одного пациента похожи, и качество на валидации оказывается завышенным.</span></dd>
<dt>Зафиксированное разбиение</dt>
<dd>Файл со списком исследований валидации, который не пересчитывается при смене правил разметки.
<span class="why">Разбиение стратифицируется по наличию нарушений, а оно зависит от меток. Без фиксации варианты сравнивались бы на разных наборах.</span></dd>
<dt>Эталон оценки</dt>
<dd>Разметка, по которой считается качество: в нашем случае — вердикт эксперта, взятый только на снимках с экспертной оценкой.
<span class="why">Оценивать вариант на его же метках — круговое сравнение. Эталон один и тот же для всех вариантов.</span></dd>
</dl>
<h4>Метрики</h4>
<dl class="defs">
<dt>ROC-AUC</dt>
<dd>Вероятность, что случайно взятый снимок с нарушением получит более высокий балл, чем случайно взятый качественный. Не зависит от порога, поэтому это основная метрика сравнения.</dd>
<dt>PR-AUC</dt>
<dd>Площадь под кривой точность-полнота. Чувствительна к доле положительного класса: при 30 % нарушений базовый уровень — 0.30, поэтому сравнивать её между наборами с разной долей нельзя.</dd>
<dt>F1, precision, recall</dt>
<dd>F1 — среднее гармоническое точности и полноты. Recall — какая доля нарушений найдена, precision — какая доля тревог подтверждается.
<span class="why">В нашей задаче пропустить нарушение дороже, чем отправить снимок на ручную проверку.</span></dd>
<dt>Доверительный интервал 95 %</dt>
<dd>Диапазон, в котором с вероятностью 95 % лежит истинное значение. Здесь интервалы считаются по пяти seed'ам обучения через распределение Стьюдента.
<span class="why">Чего он не покрывает: неопределённость самой разметки. Поэтому интервал по seed'ам не заменяет независимый тест на закрытом наборе.</span></dd>
<dt>Парная разница</dt>
<dd>Разность метрик двух вариантов, посчитанная для каждого seed'а отдельно и затем усреднённая.
<span class="why">Варианты обучались на одном разбиении и одном seed'е, поэтому разброс обучения вычитается и видно эффект самого правила.</span></dd>
</dl>
<h4>Инженерия и эксплуатация</h4>
<dl class="defs">
<dt>Контейнеризация</dt>
<dd>Решение поставляется образом с зафиксированными версиями зависимостей; запуск — скриптом в Linux и UNIX-подобных системах. Веса монтируются при запуске, данные в образ не попадают.</dd>
<dt>Офлайн-работа</dt>
<dd>Медицинские изображения не покидают контур: ни внешних сервисов, ни обращений к CDN. Стили и шрифты интерфейса лежат локально.
<span class="why">Это требование методики, а не оптимизация; оно ограничило и выбор способа разметки.</span></dd>
<dt>processing_status</dt>
<dd>Поле отчёта: <code>Success</code> или <code>Failure</code>. Необработанных исключений быть не должно — любая ошибка фиксируется в строке результата.</dd>
<dt>DICOM SR</dt>
<dd>Structured Report — текстовое структурированное представление результата.
<span class="why">Оговорка: полноценного справочника SNOMED для контролёра качества DXA в наборе нет, поэтому числовые коды условные, и рядом всегда идёт текстовая формулировка.</span></dd>
<dt>Чекпоинт</dt>
<dd>Файл с весами, порогом, параметрами препроцессинга и метаданными: на какой разметке обучен, по какому разбиению, с каким seed'ом.
<span class="why">Самодостаточность важна: инференс не может рассинхронизироваться с обучением, а по файлу видно, что именно работает.</span></dd>
</dl>
</section>
</div>
<!-- ============================ ВАРИАНТ 3 ============================ -->
<div class="panel" id="p3">
<section class="card">
<h2>Полная техническая выкладка
<span class="sub">Для вопросов «а как именно» и для инженера, который будет разворачивать решение.</span>
</h2>
<h3>1. Требования и что именно решается</h3>
<ul>
<li><strong>Объект:</strong> поясничный отдел позвоночника и проксимальный отдел бедренной кости.</li>
<li><strong>Решение:</strong> бинарная классификация — пригоден (0) или есть нарушение (1) — плюс определение анатомической области и типа нарушения.</li>
<li><strong>Вход:</strong> DICOM без разметки; в исследовании до трёх изображений.</li>
<li><strong>Выход:</strong> XLSX или CSV, одна строка на снимок: <code>path_to_study, study_uid, image_uid, anatomical_region, quality_class, violation_type, processing_status, time_of_processing</code>.</li>
<li><strong>Приоритетные метрики:</strong> F1 и ROC-AUC с 95 % доверительными интервалами.</li>
<li><strong>Ограничения:</strong> офлайн, контейнеризация, до трёх минут на исследование, воспроизводимость, отсутствие необработанных исключений.</li>
</ul>
<h3>2. Данные: состав</h3>
<table>
<thead><tr><th>Показатель</th><th class="num">Значение</th></tr></thead>
<tbody>
<tr><td>Файлов DICOM на диске</td><td class="num">544</td></tr>
<tr><td>Уникальных снимков (по пикселям)</td><td class="num">252</td></tr>
<tr><td>Исследований</td><td class="num">100</td></tr>
<tr><td>Снимков: позвоночник / бедро R / бедро L / неопределено</td><td class="num">99 / 79 / 73 / 1</td></tr>
<tr><td>Нарушений по экспертной таблице</td><td class="num">74 (29.4 %)</td></tr>
<tr><td>Нарушений в обучающей разметке</td><td class="num">77 (30.6 %)</td></tr>
<tr><td>Снимков без экспертной оценки</td><td class="num">3</td></tr>
</tbody>
</table>
<h4>Аномалии, повлиявшие на решения</h4>
<ul>
<li><strong>Дубли.</strong> Один кадр сохранён многократно; склейка по хешу пиксельных данных.</li>
<li><strong>Конфликт имён внутри одной группы дублей.</strong> Два файла одного и того же снимка названы как разные области, поэтому область определяется голосованием по именам; при равенстве голосов остаётся неопределённой (такой снимок один).</li>
<li><strong>Имя каталога ≠ StudyInstanceUID.</strong> Таблица ссылается на каталог, в DICOM лежит другой идентификатор; соответствие один к одному, 100 из 100.</li>
<li><strong>Пустые теги латеральности.</strong> Метаданных о стороне бедра нет.</li>
<li><strong>Разметки ROI нет.</strong> Ни <code>OverlayData</code>, ни <code>GraphicAnnotationSequence</code>.</li>
<li><strong>Служебные пометки в данных.</strong> Метки из них не строятся — правило выбрано измерением (§5); имена приведены к единому виду отдельным инструментом (§7).</li>
</ul>
<h3>3. Семантика экспертной таблицы</h3>
<p>Две строки заголовков, данные с третьей. Столбцы 2–4 — критерии позвоночника (укладка, ось, артефакты), 5–6 и 7–8 — позиционирование и область интереса для правого и левого бедра, 9–11 — итоги по областям, 12 — комментарий.</p>
<div class="ok">
<strong>Значение <code>1</code> в критерии означает нарушение</strong>, хотя часть заголовков сформулирована положительно («корректная укладка»). Проверено на данных: итог области равен логическому ИЛИ критериев — для бёдер 72/72 и 78/78, для позвоночника 96/99. Три расхождения позвоночника трактуются как нарушение, что соответствует правилу «хотя бы один существенный пункт нарушен».
</div>
<p>Заполненность: позвоночник 99 из 100 исследований, бедро R 72, бедро L 78; комментарий есть у 23 исследований.</p>
<h3>4. Как построена разметка на уровне снимка</h3>
<ol>
<li><strong>Ключ склейки</strong> — имя каталога исследования, а не тег DICOM.</li>
<li><strong>Склейка дублей</strong> по хешу пиксельных данных: 544 файла → 252 снимка.</li>
<li><strong>Область</strong> — голосование по именам файлов одной группы изображений.</li>
<li><strong>Перенос вердикта</strong> — оценка области переносится на её снимок. Основание: каждая область встречается в исследовании ровно один раз.</li>
<li><strong>Зеркалирование стороны.</strong> В 71 из 72 исследований с двумя бёдрами столбцы совпадают с именами файлов. В 7 исследованиях снят один снимок бедра, и в 6 из них заполнен столбец противоположной стороны: раз снимок один и заполненный столбец один, они соответствуют друг другу. Перенос фиксируется флагом <code>laterality_mirrored</code>.</li>
<li><strong>Тип нарушения</strong> берётся только из структурированных критериев. Комментарии («сколиоз», «эндо протезирование ТБС») сохранены дословно и в код не перекладываются.</li>
<li><strong>Три снимка</strong>, по которым таблица область не оценивала, получают метку из служебной пометки и помечены <code>filename_fallback</code> — иначе они остались бы без метки вообще.</li>
</ol>
<p>Результат: <code>labels/labels_images.csv</code> (и XLSX) — 252 строки, 175 качественных и 77 с нарушениями.</p>
<h3>5. Выбор правила метки: измерение</h3>
<p>Правило «только таблица» против «таблица плюс служебные пометки при подготовке набора». Пометки расходились с оценкой эксперта в 15 случаях из 252, поэтому выбор делался измерением: одно фиксированное разбиение, пять seed'ов, один эталон (вердикт эксперта на снимках валидации).</p>
<table>
<thead><tr><th>Метрика (эталон)</th><th class="num">только таблица</th><th class="num">со служебными пометками</th></tr></thead>
<tbody>
<tr><td>ROC-AUC</td><td class="num"><strong>0.6764</strong> [0.6309, 0.7218]</td><td class="num">0.6199 [0.5840, 0.6559]</td></tr>
<tr><td>PR-AUC</td><td class="num"><strong>0.4759</strong> [0.4141, 0.5377]</td><td class="num">0.4046 [0.3702, 0.4391]</td></tr>
<tr><td>F1</td><td class="num"><strong>0.5676</strong> [0.5270, 0.6082]</td><td class="num">0.5426 [0.5073, 0.5778]</td></tr>
<tr><td>Recall / Precision</td><td class="num">0.700 / 0.486</td><td class="num">0.863 / 0.404</td></tr>
</tbody>
</table>
<table>
<thead><tr><th>Парная разница (таблица − с пометками)</th><th class="num">Δ, среднее [95 % ДИ]</th><th>Знаки по seed'ам</th></tr></thead>
<tbody>
<tr><td>ROC-AUC</td><td class="num"><strong>+0.0564</strong> [+0.0403, +0.0725]</td><td><code>+++++</code></td></tr>
<tr><td>PR-AUC</td><td class="num"><strong>+0.0713</strong> [+0.0398, +0.1028]</td><td><code>+++++</code></td></tr>
<tr><td>F1</td><td class="num">+0.0251 [−0.0056, +0.0558]</td><td><code>+++-+</code></td></tr>
<tr><td>Precision</td><td class="num">+0.0815 [+0.0590, +0.1039]</td><td><code>+++++</code></td></tr>
</tbody>
</table>
<pre><code>./run.sh split # зафиксировать разбиение (стратификация по эталону)
./run.sh compare # 5 seed'ов × 2 варианта, отчёт models/compare_rules/rule_comparison.md</code></pre>
<div class="warn">
<strong>Оговорка.</strong> Эталон — та же экспертная таблица, на которой обучался победивший вариант, поэтому сравнение частично ему благоприятствует. Значимый вывод другой: добавление служебных пометок <em>снижает</em> согласие модели с экспертом на невиданных исследованиях.
</div>
<h3>6. Словарь типов нарушений</h3>
<table>
<thead><tr><th>Код</th><th>Подпись</th><th>Область</th><th>Источник</th></tr></thead>
<tbody>
<tr><td><code>positioning</code></td><td>Некорректная укладка</td><td>позвоночник</td><td><span class="tag table">таблица</span></td></tr>
<tr><td><code>axis_deviation</code></td><td>Отклонение оси</td><td>позвоночник</td><td><span class="tag table">таблица</span></td></tr>
<tr><td><code>artifact</code></td><td>Артефакты и импланты</td><td>любая</td><td><span class="tag table">таблица</span></td></tr>
<tr><td><code>rotation</code></td><td>Ротация, позиционирование</td><td>бедро</td><td><span class="tag table">таблица</span></td></tr>
<tr><td><code>roi_incorrect</code></td><td>Некорректная область интереса</td><td>любая</td><td><span class="tag table">таблица</span></td></tr>
<tr><td><code>motion</code></td><td>Движение, размытие</td><td>любая</td><td><span class="tag cond">методика</span></td></tr>
<tr><td><code>incomplete_anatomy</code></td><td>Анатомия видна не полностью</td><td>любая</td><td><span class="tag cond">методика</span></td></tr>
<tr><td><code>labeling_error</code></td><td>Ошибка разметки</td><td>позвоночник</td><td><span class="tag cond">методика</span></td></tr>
<tr><td><code>unspecified</code></td><td>Нарушение без уточнения</td><td>любая</td><td><span class="tag">служебный</span></td></tr>
</tbody>
</table>
<p class="src">Распределение в разметке: rotation 36, artifact 17, axis_deviation 10, roi_incorrect 7, positioning 6, unspecified 5.</p>
<div class="warn">
<strong>Словарь раньше существовал в трёх копиях</strong> — в модуле инференса, в API и в JavaScript интерфейса, — и ни один из них не знал кодов экспертной таблицы: в интерфейсе они показывались как есть. Теперь словарь один (<code>src/dxa/violations.py</code>), подписи отдаёт сервер, старые значения приводятся к канону.
</div>
<h3>7. Гигиена данных: имена файлов</h3>
<p>Отдельный инструмент приводит имена файлов к единому виду: область, две цифры номера, при наличии — пометка качества. Приведено 344 файла из 548, карта отката сохранена, разметка и метрики при этом не менялись.</p>
<pre><code>./run.sh rename # план правки, без изменений на диске
./run.sh rename --apply # выполнить; карта отката labels/rename_map.csv
./run.sh rename --rollback --apply # вернуть прежние имена</code></pre>
<p>Правка сделана после того, как разметка и метрики были посчитаны, и проверено, что она на них не влияет: набор из 252 пиксельных групп идентичен до и после, разметка не изменилась ни в одной строке. Пометки качества в именах в метках не участвуют — только как диагностический столбец.</p>
<h3>8. Предобработка</h3>
<pre><code>DICOM → pixel_array (RescaleSlope/Intercept, MONOCHROME1)
→ нормализация по перцентилям 0.5–99.5 → [0, 1]
→ ×255, uint8
→ повтор в 3 канала, билинейный resize 224×224
→ /255, нормировка по статистикам ImageNet
→ тензор (3, 224, 224) float32</code></pre>
<p>Один и тот же путь используется при обучении и в API. Обучение и API вызывают общий код предсказания, поэтому порог и препроцессинг совпадают по построению.</p>
<h3>9. Модель</h3>
<table>
<thead><tr><th>Компонент</th><th>Значение</th></tr></thead>
<tbody>
<tr><td>Backbone</td><td>ResNet18, веса ImageNet, <strong>заморожен</strong> (заморозка на всё обучение)</td></tr>
<tr><td>Классификационная голова</td><td>линейный слой, 2 класса; обучаемых параметров — тысячи</td></tr>
<tr><td>Вспомогательная голова области</td><td>вес в функции потерь 0.3</td></tr>
<tr><td>Вход</td><td>224×224, 3 канала; BatchNorm в eval-режиме</td></tr>
<tr><td>Порог</td><td>−0.4930 по логиту (вероятность 0.379), подбор по F1 на валидации</td></tr>
<tr><td>Размер чекпоинта</td><td>42.8 МБ, 11.2 млн параметров сети</td></tr>
<tr><td>Определение области</td><td>эвристика по ширине кадра (позвоночник ≈300 px, бедро ≈280 px)</td></tr>
</tbody>
</table>
<h4>Гиперпараметры по умолчанию</h4>
<ul>
<li>Эпох 100, batch 16, early stopping с терпением 25, отбор эпохи по сглаженному (окно 5) ROC-AUC.</li>
<li>Оптимизатор AdamW, lr 3e-4, weight decay 0.05 (decoupled).</li>
<li>Балансировка классов и аугментация: выключены.</li>
<li>Валидационная доля 0.2, разбиение по исследованиям, seed 42.</li>
</ul>
<h3>10. Оценка качества</h3>
<p>Фиксированное разбиение: обучение 199 снимков / 81 исследование, валидация 53 снимка / 19 исследований, 16 нарушений по эталону. Пять seed'ов.</p>
<table>
<thead><tr><th>Метрика</th><th class="num">Значение</th></tr></thead>
<tbody>
<tr><td>ROC-AUC</td><td class="num"><strong>0.6764</strong> [0.6309, 0.7218]</td></tr>
<tr><td>PR-AUC</td><td class="num">0.4759 [0.4141, 0.5377]</td></tr>
<tr><td>F1</td><td class="num">0.5676 [0.5270, 0.6082]</td></tr>
</tbody>
</table>
<table>
<thead><tr><th>Рабочий чекпоинт</th><th class="num">Значение</th></tr></thead>
<tbody>
<tr><td>Обучение / валидация</td><td class="num">199 снимков / 81 исследование — 53 снимка / 19 исследований, 16 нарушений</td></tr>
<tr><td>Эпоха / порог</td><td class="num">39 / логит −0.4930 (вероятность 0.379)</td></tr>
<tr><td>ROC-AUC / PR-AUC</td><td class="num">0.6706 / 0.4585</td></tr>
<tr><td>F1 / recall / precision</td><td class="num">0.5600 / 0.875 / 0.412</td></tr>
<tr><td>ROC-AUC по областям</td><td class="num">позвоночник 0.943, бедро R 0.576, бедро L 0.550</td></tr>
</tbody>
</table>
<div class="ok">
<strong>Это обычный прогон с seed по умолчанию, а не лучший из выборки.</strong> Его собственная валидационная ROC-AUC 0.6706 близка к среднему по пяти seed'ам 0.6764 — результат не отобран по удачности.
</div>
<h3>11. Проверка вклада модели: смотрит ли она на снимок</h3>
<p>В позвоночнике нарушений треть, у бёдер около трети, а область почти однозначно определяется шириной кадра. Отсюда вопрос: не выучила ли модель просто «область вместо качества». Проверка — сравнение с правилом «позвоночник значит нарушение».</p>
<table>
<thead><tr><th>Предиктор</th><th class="num">Общий AUC</th><th class="num">spine</th><th class="num">hip_right</th><th class="num">hip_left</th></tr></thead>
<tbody>
<tr><td>Модель</td><td class="num">0.854</td><td class="num">0.928</td><td class="num">0.861</td><td class="num">0.853</td></tr>
<tr><td>Правило «позвоночник = нарушение»</td><td class="num">0.529</td><td class="num">0.500</td><td class="num">0.500</td><td class="num">0.500</td></tr>
</tbody>
</table>
<p>Внутри областей правило не имеет подсказки и даёт ровно 0.5; модель — 0.85–0.93. Значит, она использует содержимое снимка. Оговорка: проверка считается на всём наборе, включая обучающие снимки, поэтому значения смещены вверх; она отвечает на вопрос «есть ли вклад содержимого», а не «каково качество на новых данных».</p>
<h3>12. Инференс, API и формат результата</h3>
<table>
<thead><tr><th>Метод</th><th>Путь</th><th>Назначение</th></tr></thead>
<tbody>
<tr><td>GET</td><td><code>/api/v1/health</code></td><td>статус и происхождение загруженной модели</td></tr>
<tr><td>GET</td><td><code>/api/v1/model</code></td><td>карточка решения: разметка, данные, метрики, словарь нарушений, ограничения</td></tr>
<tr><td>POST</td><td><code>/api/v1/analyze</code></td><td>анализ одного файла</td></tr>
<tr><td>POST</td><td><code>/api/v1/analyze/detailed</code></td><td>расширенный отчёт, при необходимости с маской</td></tr>
<tr><td>POST</td><td><code>/api/v1/analyze/sr</code></td><td>текстовое представление отчёта DICOM SR</td></tr>
<tr><td>POST</td><td><code>/api/v1/batch</code></td><td>пакетный анализ</td></tr>
<tr><td>POST</td><td><code>/api/v1/export</code></td><td>пакетный анализ и выгрузка XLSX</td></tr>
</tbody>
</table>
<p>Ответ анализа по одному файлу, сверх обязательных колонок: <code>confidence</code>, <code>threshold_probability</code>, <code>region_confidence</code>, <code>violation_type_label</code>, <code>violation_type_is_heuristic</code>, <code>violation_type_note</code>, <code>reason</code>, <code>metrics</code>, <code>samples</code>.</p>
<p>Ошибки не приводят к исключению: строка получает <code>processing_status = Failure</code>. Путь к чекпоинту задаётся переменной <code>DXA_MODEL_PATH</code>, чтобы контейнер не зависел от рабочего каталога.</p>
<h3>13. Скорость и системные требования</h3>
<table>
<thead><tr><th>Показатель</th><th class="num">Ускоритель (Apple MPS)</th><th class="num">CPU, 8 потоков</th></tr></thead>
<tbody>
<tr><td>Обработка одного снимка, медиана</td><td class="num">15 мс</td><td class="num">20 мс</td></tr>
<tr><td>Весь набор (544 файла)</td><td class="num">8 с</td><td class="num">—</td></tr>
<tr><td>На одно исследование (до 3 снимков)</td><td class="num">≈0.05 с</td><td class="num">≈0.06 с</td></tr>
<tr><td>Запас к бюджету 3 минуты</td><td class="num">&gt;3000×</td><td class="num">&gt;2500×</td></tr>
</tbody>
</table>
<p>Основное время уходит на декодирование DICOM и препроцессинг, а не на сеть: разница между ускорителем и процессором в пределах 1.4×. Значит, ускоритель для этой задачи не является узким местом. Минимальная конфигурация — CPU; чекпоинт занимает 43 МБ, память ограничена накладными расходами среды исполнения. Образ содержит только код и фронтенд; веса монтируются при запуске.</p>
<h3>14. Тесты и воспроизводимость</h3>
<ul>
<li><strong>208 тестов</strong> в <code>tests/</code>: разбор имён и склейка дублей, отсутствие утечки при разбиении, фиксация разбиения, разметка по таблице и три правила метки, единый словарь типов, приведение имён файлов с откатом, препроцессинг и метрики, контракт API для интерфейса.</li>
<li><strong>Три браузерных теста</strong> на живом сервере: переключение строк меняет панель деталей, ветка «нарушение» показывает пометку «эвристика», страница не обращается к внешним хостам.</li>
<li><strong>Воспроизводимость:</strong> фиксированные seed'ы, гиперпараметры в отчёте, состав разбиения в файле, происхождение модели внутри чекпоинта.</li>
<li><strong>Команды:</strong> <code>./run.sh label | rename | split | compare | train | infer | serve | test</code>.</li>
</ul>
<h3>15. Ограничения и что с ними делать</h3>
<table>
<thead><tr><th>Ограничение</th><th>Следствие</th><th>Что планируется</th></tr></thead>
<tbody>
<tr><td>Разметка выведена из оценки исследования</td><td>поштучной экспертной оценки снимков нет</td><td>разметка отдельных снимков специалистом</td></tr>
<tr><td>Мало данных: 252 снимка, 77 нарушений</td><td>широкие интервалы, закрытый набор может дать другие цифры</td><td>500+ исследований</td></tr>
<tr><td>Тип нарушения — эвристика</td><td>5 снимков имеют только «нарушение без уточнения»</td><td>мультилейбл-модель по типам</td></tr>
<tr><td>Эталон — та же таблица</td><td>сравнение правил частично благоприятствует таблице</td><td>независимая экспертная оценка снимков</td></tr>
<tr><td>Область определяется по ширине кадра</td><td>порог привязан к текущему оборудованию</td><td>калибровка по метаданным аппарата</td></tr>
<tr><td>Теги латеральности пусты</td><td>сторона бедра в 7 исследованиях не проверяема</td><td>запрос тега у источника данных</td></tr>
<tr><td>Разметки ROI нет в DICOM</td><td>корректность областей наследуется из таблицы</td><td>проверка на данных с экспортной разметкой</td></tr>
</tbody>
</table>
<h3>16. Негативный результат, который стоит упомянуть</h3>
<p>Планировалась визуальная разметка снимков локальной vision-моделью (9 млрд параметров, офлайн): это сняло бы зависимость от экспертной таблицы. На калибровке по 14 снимкам, из которых 8 заведомо с нарушениями, модель вынесла «непригоден» всем 14, включая все качественные, с шаблонными формулировками и выдуманными имплантами. Разделяющая способность — на уровне случайной.</p>
<div class="warn">
Вывод: разделяющую способность инструмента нужно проверять <em>до</em> того, как строить на нём пайплайн. Инструменты рендера снимков и контактных листов остались в проекте — они полезны для выборочной ручной проверки.
</div>
<h3>17. Быстрые ответы на неудобные вопросы</h3>
<dl class="defs">
<dt>Почему ROC-AUC 0.68 — это не много?</dt>
<dd>Двести пятьдесят снимков и разметка, выведенная из оценки исследования. На таком объёме это ожидаемый порядок; выше 0.8 означало бы утечку или подгонку.</dd>
<dt>Почему не дообучали всю сеть?</dt>
<dd>Пробовали: train уходит в единицу, валидация — к случайной. Замороженный backbone и линейная голова дают устойчивый результат.</dd>
<dt>Почему не использовать все доступные пометки?</dt>
<dd>Проверили: служебные пометки расходились с экспертом в 15 случаях из 252, и обучение с ними дало ROC-AUC 0.6199 против 0.6764 — хуже на всех пяти seed'ах.</dd>
<dt>Почему тип нарушения не от модели?</dt>
<dd>Разметки типов на уровне снимка мало, а мультилейбл на 77 нарушениях дал бы ещё более широкие интервалы. Честнее показать эвристику с пометкой, чем модель, которой нельзя верить.</dd>
<dt>Что если на вход придёт область, которой не было?</dt>
<dd>Область определяется эвристикой; при неуверенности снимок относится к неизвестной области, а решение всё равно формируется и помечается в отчёте.</dd>
<dt>Можно ли доверять метрике на закрытом наборе?</dt>
<dd>Ожидаемо близко к валидационной, но с оговорками: порог подобран на валидации, объём мал, эталон — та же таблица. Ориентир — 0.68, а не выше.</dd>
</dl>
</section>
</div>
<footer class="page">
Все числа получены из артефактов проекта. Источники: <code>labels/labels_images.csv</code>, <code>models/compare_rules/rule_comparison.md</code>, <code>models/train_report.json</code>, <code>docs/labeling.md</code>, <code>README.md</code>, <code>QWEN.md</code>.
Страница самодостаточна и не обращается к внешним ресурсам.
</footer>
</div>
<script>
// Переключение вариантов. Без JavaScript виден только первый вариант,
// поэтому в <head> есть <noscript> с показом всех трёх подряд.
(function () {
var tabs = Array.prototype.slice.call(document.querySelectorAll('.tabs button'));
var panels = Array.prototype.slice.call(document.querySelectorAll('.panel'));
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
tabs.forEach(function (t) { t.setAttribute('aria-selected', String(t === tab)); });
panels.forEach(function (p) { p.classList.toggle('active', p.id === tab.dataset.panel); });
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
})();
</script>
</body>
</html>