Недавно мой друг Джон Поль Кук, дипломированный медицинский работник и обладатель статуса SQL Server MVP, попросил помочь в решении задачи, связанной с моделированием назначения лекарственных препаратов в виде интервальных графов. Подробное описание задачи дано в статье Сяоцян Вана «Counting Drug Exposure in SAS® with Interval Graph Modeling» (www.nesug.org/Proceedings/nesug10/hl/hl06.pdf). Автор статьи описывает задачу и приводит ее решение с использованием SAS. Джон попросил меня найти оптимальное решение задачи на основе T-SQL.

Я решал множество головоломок T-SQL, мне нравится этот процесс. Часто приходится проводить не один час в поисках рациональных, элегантных решений. А иногда удается найти новые закономерности, пригодные и для решения других задач. Именно так произошло с данной задачей.

Как уже отмечалось, она связана с моделированием назначения лекарственных препаратов в виде интервальных графов. Запустите программный код, приведенный в листинге 1, чтобы сформировать таблицы Patients и Prescriptions, и заполните их малым набором тестовых данных. Программный код листинга 1 также создает индексы для описываемых решений.

Можно использовать малый набор тестовых данных для сопоставления ваших решений с желательными результатами, которые будут приведены в статье. Вам потребуется более крупный набор тестовых данных для тестирования производительности решений. Исходный текст в листинге 2 формирует вспомогательную таблицу с именем GetNums, которая возвращает последовательность целых чисел в запрошенном диапазоне, а затем использует эту функцию для заполнения таблиц более крупными наборами тестовых данных.

Таблица Prescriptions содержит информацию о назначении лекарственных препаратов для пациентов. Она состоит из следующих столбцов: prescriptionid (суррогатный ключ), patientid, drugid, startdate (начало периода действия рецептуры) и numdays (число дней действия рецепта). В таблице также определен вычисляемый столбец enddate для даты окончания действия рецепта (это день, следующий за последним днем приема лекарства). Например, если трехдневный срок приема лекарства начинается 1 января 2014 года (число дней — 3), то датой окончания будет 4 января 2014 года. Пациент принимает лекарство 1, 2 и 3 января, но не получает дозу 4 января.

Задача, описанная в статье «Counting Drug Exposure in SAS® with Interval Graph Modeling», разделяется на две части:

  1. Упаковка пересекающихся периодов назначения лекарственных препаратов.
  2. Обнаружение периодов, в течение которых пациент принимает определенное минимальное число лекарств.

Передавая мне задачу, Джон сказал: «Исследователи изучают рецепты в поисках потенциальных побочных эффектов. Если быть откровенным, то задача — не допустить летального исхода».

Трудно придумать более действенный стимул для поиска эффективного решения!

Примечательно, что приступая к работе над задачей, я полагал, что упаковка не вызовет затруднений. Однако именно эта часть оказалась самой трудоемкой. В данной статье описывается проблема упаковки и приводятся решения на основе курсора и на основе наборов. Проблема подсчета будет рассмотрена в следующей статье.

Упаковка

Проблема упаковки заключается в упаковке пересекающихся рецептурных периодов для одного и того же пациента и лекарства. Но есть особенность: пациент получает не более одной дозы лекарства в тот же день, даже если существует несколько параллельных рецептурных периодов. Рецепт, действие которого начинается позже (в случае одновременного начала — рецепт с большим значением суррогатного ключа) передвигается вперед во времени на дату окончания рецепта, начавшегося ранее. Вероятно, этот подход проще понять с помощью иллюстрации.

На рисунке 1 графически показаны входные рецептурные периоды на основе малого набора тестовых данных, а также стрелок, представляющих упакованные периоды (конечная дата представляет день, следующий за последним днем приема препарата).

 

Входные и упакованные интервалы
Рисунок 1. Входные и упакованные интервалы

Обратите внимание на три рецептурных периода для Patient 1, Drug 4:

1—startdate: 1/2; numdays: 5
2—startdate: 1/3; numdays: 2
3—startdate: 1/8; numdays: 1

Первый рецепт начинается 2 января и длится 5 дней, поэтому истекает 7 января. Второй рецепт начинается до 7 января, поэтому он переносится на эту дату. Продолжительность второго рецепта — 2 дня, поэтому он завершается 9 января. Третий рецепт начинается до 9 января, поэтому он перенесен на эту дату. Продолжительность третьего рецепта — 1 день, он истекает 10 января. Упакованный рецептурный период представлен стрелкой над отдельными рецептурными периодами; он начинается 2 января и завершается 10 января.

Задача состоит в том, чтобы записать решение, которое выдает упакованные рецептурные периоды для каждого пациента и препарата на основе описанной логики. На рисунке 2 показаны желаемые результаты на основе малого набора тестовых данных.

 

Упакованные рецепты
Рисунок 2. Упакованные рецепты

Я рассматриваю как решения на основе курсора, так и на основе наборы. Предоставляются метрики производительности, полученные при запуске решений с большим набором тестовых данных. Результаты сбрасываются в среде SQL Server Management Studio (SSMS). Чтобы сбросить результаты после выполнения, щелкните правой кнопкой мыши пустую область на панели запроса, выберите Query Options («Параметры запроса»), затем Discard results after execution («Отбросить результаты после выполнения») и подтвердите режим в разделе Results-Grid. Таким образом запрещается вывод результирующих строк на печать в сетке.

Решение на основе курсора

Решение на основе курсора довольно простое, с обработкой данных за один проход. В листинге 3 показан полный исходный текст решения. Объявляется табличная переменная с именем @Result, в которой хранятся упакованные интервалы.

Строки из таблицы подаются в курсор, сортируются сначала по patientid и drugid (поскольку вычисления производятся отдельно для каждого пациента и лекарственного препарата), затем по startdate и prescriptionid (для разрешения конфликтов). Запрос, который передает данные в курсор, оптимизирован путем упорядоченного сканирования индекса idx_start, поэтому операции сортировки не требуется.

Организуется циклическая обработка записей курсора по одной, с подачей значений текущей записи в локальные переменные. В переменной @firststartdate хранится дата начала недавно упакованного интервала, а в переменной @sumnumdays — число рецептурных дней с нарастающим итогом для недавно упакованного интервала. Для каждой извлеченной записи проверяется следующее условие, чтобы выяснить, нужно ли закрыть текущий упакованный интервал и начать новый:

IF @prevpatientid <> @patientid
OR @prevdrugid <> @drugid
OR DATEADD(day, @sumnumdays, @firststartdate) < @startdate

Текущий упакованный интервал закрывается, если выполнено любое из следующих условий:

  • любой из элементов группы (patientid или drugid) изменяется;
  • начальная дата текущего упакованного интервала (@firststartdate) плюс число рецептурных дней с нарастающим итогом с момента его начала (@sumnumdays) меньше начальной даты нового рецептурного интервала.

В случае совпадения новый упакованный интервал закрывается, его информация записывается в табличную переменную, затем открывается новый упакованный интервал путем инициализации @firststartdate значением @startdate и @sumnumdays значением 0. Если текущий упакованный интервал не закрыт (то есть новый интервал должен продлить его), значение @sumnumdays увеличивается на @numdays.

После завершения цикла информация о последнем упакованном интервале вставляется в табличную переменную (если применимо), затем курсор закрывается и переназначается. На моем компьютере решение на основе курсора было выполнено за 31 секунду.

Решение на основе наборов

В решении на основе наборов вычисляется идентификатор группы (назовем его grp), который связывает каждый рецептурный интервал с упакованным интервалом, которому он принадлежит. Это делается в два шага, показанных на рисунке 3, наряду с примерами, объясняющими, почему значение grp уникально для упакованного интервала.

 

Вычисление grphelper и grp
Рисунок 3. Вычисление grphelper и grp

На первом шаге вычисляется значение, именуемое grphelper. Это значение вычисляется как текущее значение startdate минус сумма с нарастающим итогом всех значений numdays интервалов на данный момент, кроме текущего интервала. Эти вычисления реализованы в следующем запросе:

SELECT prescriptionid, patientid, drugid, startdate, numdays,
DATEADD(day,
— SUM(numdays) OVER(PARTITION BY patientid, drugid
ORDER BY startdate, prescriptionid
ROWS UNBOUNDED PRECEDING) + numdays,
startdate) AS grphelper
FROM dbo.Prescriptions;

На рисунке 4 показан вывод запроса для малого набора тестовых данных.

 

Вычисление grphelper
Рисунок 4. Вычисление grphelper

Второй шаг заключается в вычислении grp как максимального на данный момент значения grphelper. В следующем запросе это вычисление добавляется сверх предшествующего запроса, представленного CTE C1:

WITH C1 AS
(
SELECT prescriptionid, patientid, drugid, startdate, numdays,
DATEADD(day,
— SUM(numdays) OVER(PARTITION BY patientid, drugid
ORDER BY startdate, prescriptionid
ROWS UNBOUNDED PRECEDING) + numdays,
startdate) AS grphelper
FROM dbo.Prescriptions
)
SELECT *,
MAX(grphelper) OVER(PARTITION BY patientid, drugid
ORDER BY startdate, prescriptionid
ROWS UNBOUNDED PRECEDING) AS grp
FROM C1;

На рисунке 5 показан вывод этого запроса. Он содержит как значения grphelper, так и grp.

 

Вычисление grp
Рисунок 5. Вычисление grp

На рисунке 3 представлены четыре примера с сопутствующими объяснениями, из которых можно сделать вывод, что значение grp уникально для каждого упакованного интервала. Обратите внимание, что RT представляет сумму с нарастающим итогом для количества дней numdays, исключая текущий интервал.

Первые два примера относятся к двум первым интервалам для данного пациента и лекарства; интервал I1 представляет самый первый интервал, а I2 представляет второй интервал (начавшийся одновременно или после начала I1). Последние два пример похожи, единственное различие — в существовании рецептов перед I1 и I2.

В первом примере предполагается, что I2 перекрывается с I1. В данном случае grphelper1 = s1 — RT1 = s1 — 0 = s1.

Поскольку в этом примере I2 пересекается с I1, grphelper2 (s2 — d1) <= s1. Таким образом, grphelper2 <= grphelper1, и в результате grp2 = grp1. Другими словами, если два интервала перекрываются, оба получают одинаковое значение grp и потому принадлежат к одному упакованному интервалу.

Во втором примере предполагается, что I2 начинается после завершения I1. В данном случае grphelper2 (s2 — d1) > grphelper1 (s1). Поэтому grp2 > grp1. Другими словами, два интервала получают различные значения grp и принадлежат к разным упакованным интервалам.

Третий и четвертый примеры похожи на первый и второй, соответственно; однако I1 и I2 не являются первыми рецептурными интервалами. Различие в этих примерах в том, что значения RT для I1 и I2 содержат дополнительную величину delta, равную нарастающему итогу значений numdays для интервалов, начавшихся прежде интервалов I1 и I2.

Третий пример — I2 пересекается с I1, но существуют интервалы, предшествующие I1. Теперь нужно учесть delta, но поскольку delta вычитается из обеих сторон уравнения, то результат не изменяется. В этом примере I2 пересекает I1, поэтому grphelper2 (s2 — d1 — delta) <= s1 — delta. Таким образом, grphelper2 <= grphelper1. Следовательно, grp2 = grp1. Другими словами, когда I2 пересекает I1, даже если существуют предшествующие рецепты, значения grp обоих по-прежнему одинаковы, и они принадлежат к одному упакованному интервалу.

Четвертый пример — I2 следует за I1, но с существующими предыдущими рецептами. В данном случае grphelper2 (s2 — d1 — delta) > grphelper1 (s1 — delta). Поэтому grp2 > grp1. Двум интервалам назначаются различные значения grp; следовательно, они принадлежат различным упакованным интервалам.

Полученное значение grp уникально идентифицирует упакованные интервалы для одного пациента и группы препаратов. Остается сгруппировать строки по значениям patientid, drugid и grp и вычислить начало и конец каждого упакованного интервала. В листинге 4 приведен полный исходный текст запроса.

На рисунке 6 показан план запроса.

 

План для запроса в листинге 4
Рисунок 6. План для запроса в листинге 4

Интересная особенность плана запроса — индекс idx_start сканируется только один раз в ORDER, и вычисления оконной функции основываются на этом ORDER. Ни одно из них не требует явной операции сортировки. Поэтому данному решению свойственно эффективное линейное масштабирование. На моем компьютере для его выполнения потребовалось 4 секунды.

Тем не менее, существует потенциал для улучшения методов обработки оконных агрегатных функций оптимизатором. В настоящее время оконные агрегатные функции с фреймом оптимизированы с использованием двух операторов, именуемых Window Spool и Stream Aggregate. Оператор Window Spool представляет список строк, а оператор Stream Aggregate вычисляет эти строки. Если фрейм начинается с UNBOUNDED PRECEDING, оптимизатор идентифицирует случай как fast track и вместо того, чтобы записать все применимые строки во фрейм, а затем обработать их, получает предыдущее накопление строки и добавляет значение текущей строки, вычисляя новый нарастающий итог.

Проблема в том, что SQL Server записывает одну строку в очередь с агрегатом, а другую с подробностями (помните, что в отличие от групповых, оконные функции не скрывают подробности). Имеется огромный потенциал для оптимизации особого (и очень распространенного) случая с фреймом ROWS UNBOUNDED PRECEDING. Теоретически данный вариант может быть оптимизирован, как и функция ROW_NUMBER: с помощью оператора Sequence Project, который вычисляет значения результата на ходу, при этом не требуется дополнительная работа по записи строк в Window Spool (рабочую таблицу) и их агрегирования. Такая оптимизация должна обеспечить гораздо более быстрое выполнение запросов: вероятно, примерно за 1 секунду вместо 4 секунд. Надеюсь, что компания Microsoft углубит оптимизацию оконных функций, чтобы повысить производительность решений на основе наборов.

Что дальше?

В этой статье мы рассмотрели первую часть рецептурной головоломки — упаковку пересекающихся рецептурных периодов. Описано медленное решение на основе курсора, для выполнения которого потребовалась 31 секунда. Затем было приведено гораздо более быстрое решение на основе наборов, в котором используются оконные агрегатные функции для вычисления группового идентификатора упакованных интервалов. В следующей статье будет рассмотрена вторая часть задачи — идентификация рецептурных периодов, в течение которых один пациент принимал определенное минимальное число лекарственных препаратов.

Листинг 1. DDL для формирования таблицы Patients и Prescriptions с малым набором тестовых данных

SET NOCOUNT ON;
USE tempdb;
IF OBJECT_ID(N'dbo.Prescriptions', N'U') IS NOT NULL
DROP TABLE dbo.Prescriptions;
IF OBJECT_ID(N'dbo.Patients', N'U') IS NOT NULL
DROP TABLE dbo.Patients;
CREATE TABLE dbo.Patients
(
patientid INT NOT NULL,
CONSTRAINT PK_Patients PRIMARY KEY(patientid)
);
CREATE TABLE dbo.Prescriptions
(
prescriptionid INT NOT NULL IDENTITY,
patientid INT NOT NULL,
drugid INT NOT NULL,
startdate DATE NOT NULL,
numdays INT NOT NULL,
enddate AS DATEADD(day, numdays, startdate),
CONSTRAINT CHK_Prescriptions_ed_sd CHECK(numdays > 0)
);
CREATE UNIQUE CLUSTERED INDEX idx_start
ON dbo.Prescriptions
(patientid, drugid, startdate, prescriptionid);
ALTER TABLE dbo.Prescriptions
ADD CONSTRAINT PK_Prescriptions PRIMARY KEY
NONCLUSTERED(prescriptionid);
— код для заполнения таблиц с малым набором тестовых данных
TRUNCATE TABLE dbo.Prescriptions;
TRUNCATE TABLE dbo.Patients;
INSERT INTO dbo.Patients(patientid) VALUES(1);
INSERT INTO dbo.Prescriptions
(patientid, drugid, startdate, numdays) VALUES
(1, 1, '20140101', 3),
(1, 2, '20140101', 5),
(1, 3, '20140102', 4),
(1, 4, '20140102', 5),
(1, 4, '20140103', 2),
(1, 2, '20140105', 5),
(1, 3, '20140106', 4),
(1, 1, '20140107', 3),
(1, 4, '20140108', 1),
(1, 4, '20140120', 4),
(1, 4, '20140122', 1),
(1, 5, '20140212', 3),
(1, 5, '20140215', 3),
(1, 5, '20140220', 1),
(1, 5, '20140223', 1),
(1, 5, '20140226', 1);

Листинг 2. Программный код для создания вспомогательной таблицы GetNums и крупного набора тестовых данных

IF OBJECT_ID(N'dbo.GetNums', N'IF') IS NOT NULL
DROP FUNCTION dbo.GetNums;
GO
CREATE FUNCTION dbo.GetNums(@low AS BIGINT, @high AS BIGINT) RETURNS TABLE
AS
RETURN
WITH
L0 AS (SELECT c FROM (SELECT 1 UNION ALL SELECT 1)
AS D(c)),
L1 AS (SELECT 1 AS c FROM L0 AS A CROSS JOIN L0 AS B),
L2 AS (SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B),
L3 AS (SELECT 1 AS c FROM L2 AS A CROSS JOIN L2 AS B),
L4 AS (SELECT 1 AS c FROM L3 AS A CROSS JOIN L3 AS B),
L5 AS (SELECT 1 AS c FROM L4 AS A CROSS JOIN L4 AS B),
Nums AS (SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL))
AS rownum
FROM L5)
SELECT TOP(@high — @low + 1) @low + rownum — 1 AS n
FROM Nums
ORDER BY rownum;
GO
DECLARE
@numpatients AS INT = 10000,
@numdrugs AS INT = 10,
@numprescriptions AS INT = 10,
@firststartdate AS DATE = '20140101',
@laststartdate AS DATE = '20141231',
@maxnumdays AS INT = 30;
TRUNCATE TABLE dbo.Prescriptions;
TRUNCATE TABLE dbo.Patients;
INSERT INTO dbo.Patients(patientid)
SELECT PT.n AS patientid
FROM dbo.GetNums(1, @numpatients) AS PT;
INSERT INTO dbo.Prescriptions
(patientid, drugid, startdate, numdays)
SELECT
PT.n AS patientid,
D.n AS drugid,
DATEADD(day,
ABS(CHECKSUM(NEWID()))
% DATEDIFF(day, @firststartdate, @laststartdate),
@firststartdate ) AS startdate,
ABS(CHECKSUM(NEWID())) % @maxnumdays + 1 AS numdays
FROM dbo.GetNums(1, @numpatients) AS PT
CROSS JOIN dbo.GetNums(1, @numdrugs) AS D
CROSS JOIN dbo.GetNums(1, @numprescriptions) AS PR;

Листинг 3. Решение на основе курсора для упаковки рецептов

SET NOCOUNT ON;
DECLARE @Result AS TABLE
(
patientid INT NOT NULL,
drugid INT NOT NULL,
startdate DATE NOT NULL,
enddate DATE NOT NULL
);
DECLARE
@patientid AS INT,
@drugid AS INT,
@startdate AS DATE,
@numdays AS INT,
@sumnumdays AS INT,
@prevpatientid AS INT,
@prevdrugid AS INT,
@firststartdate AS DATE;
DECLARE C FAST_FORWARD FOR
SELECT patientid, drugid, startdate, numdays
FROM dbo.Prescriptions
ORDER BY patientid, drugid, startdate, prescriptionid;
OPEN C;
FETCH NEXT FROM C INTO
@patientid, @drugid, @startdate, @numdays;
SELECT
@prevpatientid = @patientid,
@prevdrugid = @drugid,
@firststartdate = @startdate,
@sumnumdays = 0;
WHILE @@fetch_status = 0
BEGIN
IF @prevpatientid <> @patientid
OR @prevdrugid <> @drugid
OR DATEADD(day, @sumnumdays, @firststartdate)
< @startdate
BEGIN
INSERT INTO @Result(patientid, drugid, startdate, enddate)
VALUES(@prevpatientid, @prevdrugid, @firststartdate,
DATEADD(day, @sumnumdays, @firststartdate));
SELECT
@firststartdate = @startdate,
@sumnumdays = 0;
END
SELECT
@sumnumdays += @numdays,
@prevpatientid = @patientid,
@prevdrugid = @drugid;
FETCH NEXT FROM C INTO
@patientid, @drugid, @startdate, @numdays;
END
IF @sumnumdays > 0
INSERT INTO @Result(patientid, drugid, startdate, enddate)
VALUES(@prevpatientid, @prevdrugid, @firststartdate,
DATEADD(day, @sumnumdays, @firststartdate));
CLOSE C;
DEALLOCATE C;
SELECT * FROM @Result;

Листинг 4. Решение на основе наборов для упаковки рецептов

WITH C1 AS
(
SELECT prescriptionid, patientid, drugid, startdate, numdays,
DATEADD(day,
— SUM(numdays) OVER(PARTITION BY patientid, drugid
ORDER BY startdate, prescriptionid
ROWS UNBOUNDED PRECEDING)
+ numdays,
startdate) AS grphelper
FROM dbo.Prescriptions
),
C2 AS
(
SELECT *,
MAX(grphelper) OVER(PARTITION BY patientid, drugid
ORDER BY startdate, prescriptionid
ROWS UNBOUNDED PRECEDING) AS grp
FROM C1
)
SELECT
patientid, drugid,
MIN(startdate) AS startdate,
DATEADD(day, SUM(numdays), MIN(startdate)) AS enddate
FROM C2
GROUP BY patientid, drugid, grp;