¿Cómo puedo escribir una consulta de ventanas que sume una columna para crear cubos discretos?

11

Tengo una tabla que incluye una columna de valores decimales, como este:

id value size
-- ----- ----
 1   100  .02
 2    99  .38
 3    98  .13
 4    97  .35
 5    96  .15
 6    95  .57
 7    94  .25
 8    93  .15

Lo que necesito lograr es un poco difícil de describir, así que tengan paciencia conmigo. Lo que intento hacer es crear un valor agregado de la sizecolumna que se incremente en 1 cada vez que las filas anteriores sumen 1, cuando están en orden descendente de acuerdo con value. El resultado se vería así:

id value size bucket
-- ----- ---- ------
 1   100  .02      1
 2    99  .38      1
 3    98  .13      1
 4    97  .35      1
 5    96  .15      2
 6    95  .57      2
 7    94  .25      2
 8    93  .15      3

Mi primer intento ingenuo fue mantener un funcionamiento SUMy luego CEILINGese valor, sin embargo, no maneja el caso donde algunos registros sizeterminan contribuyendo al total de dos cubos separados. El siguiente ejemplo podría aclarar esto:

id value size crude_sum crude_bucket distinct_sum bucket
-- ----- ---- --------- ------------ ------------ ------
 1   100  .02       .02            1          .02      1
 2    99  .38       .40            1          .40      1
 3    98  .13       .53            1          .53      1
 4    97  .35       .88            1          .88      1
 5    96  .15      1.03            2          .15      2
 6    95  .57      1.60            2          .72      2
 7    94  .25      1.85            2          .97      2
 8    93  .15      2.00            2          .15      3

Como puede ver, si simplemente usara CEILINGen el crude_sumregistro # 8 se asignaría al depósito 2. Esto se debe a que los sizeregistros # 5 y # 8 se dividen en dos depósitos. En cambio, la solución ideal es restablecer la suma cada vez que llega a 1, que luego incrementa la bucketcolumna y comienza una nueva SUMoperación que comienza en el sizevalor del registro actual. Debido a que el orden de los registros es importante para esta operación, he incluido la valuecolumna, que debe ordenarse en orden descendente.

Mis intentos iniciales han implicado hacer múltiples pases sobre los datos, una vez para realizar la SUMoperación, una vez más para CEILINGeso, etc. Aquí hay un ejemplo de lo que hice para crear la crude_sumcolumna:

SELECT
  id,
  value,
  size,
  (SELECT TOP 1 SUM(size) FROM table t2 WHERE t2.value<=t1.value) as crude_sum
FROM
  table t1

Que se utilizó en una UPDATEoperación para insertar el valor en una tabla para trabajar más tarde.

Editar: me gustaría dar otra puñalada para explicar esto, así que aquí va. Imagine que cada registro es un elemento físico. Ese artículo tiene un valor asociado y un tamaño físico menor que uno. Tengo una serie de cubos con una capacidad de volumen de exactamente 1, y necesito determinar cuántos de estos cubos necesitaré y en qué cubo entra cada artículo de acuerdo con el valor del artículo, ordenados de mayor a menor.

Un elemento físico no puede existir en dos lugares a la vez, por lo que debe estar en un cubo u otro. Es por eso que no puedo hacer una CEILINGsolución total + , porque eso permitiría que los registros contribuyan con su tamaño a dos cubos.

Zikes
fuente
Debe agregar su SQL para dejar en claro qué incluyó su intento inicial.
mdahlman
¿Va a agregar datos de acuerdo con el depósito que está calculando, o es el número del depósito la respuesta final que está buscando?
Jon Seigel
2
Ack Probablemente iría con una aplicación del lado del cliente, ya que admitirá una mejor transmisión de registros en lugar de un bucle de cursor que obtiene una fila a la vez. Creo que siempre y cuando todas las actualizaciones se realicen en lotes, debería funcionar razonablemente bien.
Jon Seigel
1
Como los otros ya han mencionado, el requisito de gastar dinero distinct_countcomplica las cosas. Aaron Bertrand tiene un excelente resumen de sus opciones en SQL Server para este tipo de trabajo de ventanas. He utilizado el método de "actualización peculiar" para calcular distinct_sum, que puedes ver aquí en SQL Fiddle , pero esto no es confiable.
Nick Chammas
1
@ JonSeigel Debemos tener en cuenta que el problema de colocar elementos X en un número mínimo de cubos no se puede resolver de manera eficiente utilizando un algoritmo fila por fila del lenguaje SQL. Por ejemplo, los artículos de tamaño 0.7; 0.8; 0.3 necesitarán 2 cubos, pero si se ordenan por id necesitarán 3 cubos.
Stoleg

Respuestas:

9

No estoy seguro de qué tipo de rendimiento está buscando, pero si CLR o la aplicación externa no son una opción, lo único que queda es un cursor. En mi vieja computadora portátil paso por 1,000,000 de filas en aproximadamente 100 segundos usando la siguiente solución. Lo bueno de esto es que se escala linealmente, por lo que estaría mirando unos 20 minutos para recorrer todo. Con un servidor decente, será más rápido, pero no un orden de magnitud, por lo que aún le tomará varios minutos completar esto. Si este es un proceso único, probablemente pueda permitirse la lentitud. Si necesita ejecutar esto como un informe o similar con regularidad, es posible que desee almacenar los valores en la misma tabla y actualizarlos a medida que se agregan nuevas filas, por ejemplo, en un desencadenante.

De todos modos, aquí está el código:

IF OBJECT_ID('dbo.MyTable') IS NOT NULL DROP TABLE dbo.MyTable;

CREATE TABLE dbo.MyTable(
 Id INT IDENTITY(1,1) PRIMARY KEY CLUSTERED,
 v NUMERIC(5,3) DEFAULT ABS(CHECKSUM(NEWID())%100)/100.0
);


MERGE dbo.MyTable T
USING (SELECT TOP(1000000) 1 X FROM sys.system_internals_partition_columns A,sys.system_internals_partition_columns B,sys.system_internals_partition_columns C,sys.system_internals_partition_columns D)X
ON(1=0)
WHEN NOT MATCHED THEN
INSERT DEFAULT VALUES;

--SELECT * FROM dbo.MyTable

DECLARE @st DATETIME2 = SYSUTCDATETIME();
DECLARE cur CURSOR FAST_FORWARD FOR
  SELECT Id,v FROM dbo.MyTable
  ORDER BY Id;

DECLARE @id INT;
DECLARE @v NUMERIC(5,3);
DECLARE @running_total NUMERIC(6,3) = 0;
DECLARE @bucket INT = 1;

CREATE TABLE #t(
 id INT PRIMARY KEY CLUSTERED,
 v NUMERIC(5,3),
 bucket INT,
 running_total NUMERIC(6,3)
);

OPEN cur;
WHILE(1=1)
BEGIN
  FETCH NEXT FROM cur INTO @id,@v;
  IF(@@FETCH_STATUS <> 0) BREAK;
  IF(@running_total + @v > 1)
  BEGIN
    SET @running_total = 0;
    SET @bucket += 1;
  END;
  SET @running_total += @v;
  INSERT INTO #t(id,v,bucket,running_total)
  VALUES(@id,@v,@bucket, @running_total);
END;
CLOSE cur;
DEALLOCATE cur;
SELECT DATEDIFF(SECOND,@st,SYSUTCDATETIME());
SELECT * FROM #t;

GO 
DROP TABLE #t;

Cae y recrea la tabla MyTable, la llena con 1000000 filas y luego se pone a trabajar.

El cursor copia cada fila en una tabla temporal mientras ejecuta los cálculos. Al final, la selección devuelve los resultados calculados. Puede ser un poco más rápido si no copia los datos, sino que realiza una actualización en su lugar.

Si tiene una opción para actualizar a SQL 2012, puede ver los nuevos agregados de ventana móvil compatibles con spool de ventana, que deberían proporcionarle un mejor rendimiento.

En una nota al margen, si tiene un ensamblado instalado con permission_set = safe, puede hacer más cosas malas a un servidor con T-SQL estándar que con el ensamblaje, por lo que seguiría trabajando para eliminar esa barrera. Tiene un buen uso caso aquí donde CLR realmente te ayudaría.

Sebastian Meine
fuente
Acepté este debido a lo fácil que fue implementarlo y lo fácil que puedo cambiarlo y depurarlo más tarde cuando surja la necesidad. La respuesta de @ NickChammas también es correcta y probablemente se ejecuta de manera más eficiente, por lo que supongo que es una cuestión de preferencia para cualquier otra persona que se encuentre con un problema similar.
Zikes
9

En ausencia de las nuevas funciones de ventanas en SQL Server 2012, las ventanas complejas se pueden lograr con el uso de CTE recursivos. Me pregunto qué tan bien funcionará esto en millones de filas.

La siguiente solución cubre todos los casos que describió. Puede verlo en acción aquí en SQL Fiddle .

-- schema setup
CREATE TABLE raw_data (
    id    INT PRIMARY KEY
  , value INT NOT NULL
  , size  DECIMAL(8,2) NOT NULL
);

INSERT INTO raw_data 
    (id, value, size)
VALUES 
   ( 1,   100,  .02) -- new bucket here
 , ( 2,    99,  .99) -- and here
 , ( 3,    98,  .99) -- and here
 , ( 4,    97,  .03)
 , ( 5,    97,  .04)
 , ( 6,    97,  .05)
 , ( 7,    97,  .40)
 , ( 8,    96,  .70) -- and here
;

Ahora respira hondo. Aquí hay dos CTE clave, cada uno precedido por un breve comentario. El resto son solo CTE de "limpieza", por ejemplo, para extraer las filas correctas después de haberlas clasificado.

-- calculate the distinct sizes recursively
WITH distinct_size AS (
  SELECT
      id
    , size
    , 0 as level
  FROM raw_data

  UNION ALL

  SELECT 
      base.id
    , CAST(base.size + tower.size AS DECIMAL(8,2)) AS distinct_size
    , tower.level + 1 as level
  FROM 
                raw_data AS base
    INNER JOIN  distinct_size AS tower
      ON base.id = tower.id + 1
  WHERE base.size + tower.size <= 1
)
, ranked_sum AS (
  SELECT 
      id
    , size AS distinct_size
    , level
    , RANK() OVER (PARTITION BY id ORDER BY level DESC) as rank
  FROM distinct_size  
)
, top_level_sum AS (
  SELECT
      id
    , distinct_size
    , level
    , rank
  FROM ranked_sum
  WHERE rank = 1
)
-- every level reset to 0 means we started a new bucket
, bucket AS (
  SELECT
      base.id
    , COUNT(base.id) AS bucket
  FROM 
               top_level_sum base
    INNER JOIN top_level_sum tower
      ON base.id >= tower.id
  WHERE tower.level = 0
  GROUP BY base.id
)
-- join the bucket info back to the original data set
SELECT
    rd.id
  , rd.value
  , rd.size
  , tls.distinct_size
  , b.bucket
FROM 
             raw_data rd
  INNER JOIN top_level_sum tls
    ON rd.id = tls.id
  INNER JOIN bucket   b
    ON rd.id = b.id
ORDER BY
  rd.id
;

Esta solución supone que ides una secuencia sin espacios. De lo contrario, deberá generar su propia secuencia sin espacios agregando un CTE adicional al principio que numere las filas ROW_NUMBER()según el orden deseado (por ejemplo ROW_NUMBER() OVER (ORDER BY value DESC)).

Fankly, esto es bastante detallado.

Nick Chammas
fuente
1
Esta solución no parece abordar el caso en el que una fila podría contribuir con su tamaño a múltiples cubos. Una suma continua es bastante fácil, pero necesito restablecerla cada vez que llega a 1. Vea la última tabla de ejemplo en mi pregunta y compárela crude_sumcon distinct_sumsus bucketcolumnas asociadas para ver a qué me refiero.
Zikes
2
@Zikes: he abordado este caso con mi solución actualizada.
Nick Chammas
Parece que debería funcionar ahora. Trabajaré para integrarlo en mi base de datos para probarlo.
Zikes
@Zikes: por curiosidad, ¿cómo funcionan las diversas soluciones publicadas aquí en relación con su gran conjunto de datos? Supongo que Andriy's es el más rápido.
Nick Chammas
5

Esto se siente como una solución tonta, y probablemente no escalará bien, así que pruebe cuidadosamente si lo usa. Como el problema principal proviene del "espacio" que queda en el cubo, primero tuve que crear un registro de relleno para unir los datos.

with bar as (
select
  id
  ,value
  ,size
  from foo
union all
select
  f.id
  ,value = null
  ,size = 1 - sum(f2.size) % 1
  from foo f
  inner join foo f2
    on f2.id < f.id
  group by f.id
    ,f.value
    ,f.size
  having cast(sum(f2.size) as int) <> cast(sum(f2.size) + f.size as int)
)
select
  f.id
  ,f.value
  ,f.size
  ,bucket = cast(sum(b.size) as int) + 1
  from foo f
  inner join bar b
    on b.id <= f.id
  group by f.id
    ,f.value
    ,f.size

http://sqlfiddle.com/#!3/72ad4/14/0

SQLFox
fuente
1
+1 Creo que esto tiene potencial si hay índices apropiados.
Jon Seigel
3

La siguiente es otra solución recursiva de CTE, aunque diría que es más sencillo que la sugerencia de @ Nick . En realidad, está más cerca del cursor de @ Sebastian , solo que utilicé diferencias en lugar de totales. (Al principio incluso pensé que la respuesta de @ Nick iba a estar en la línea de lo que estoy sugiriendo aquí, y es después de saber que la suya fue una pregunta muy diferente que decidí ofrecer la mía).

WITH rec AS (
  SELECT TOP 1
    id,
    value,
    size,
    bucket        = 1,
    room_left     = CAST(1.0 - size AS decimal(5,2))
  FROM atable
  ORDER BY value DESC
  UNION ALL
  SELECT
    t.id,
    t.value,
    t.size,
    bucket        = r.bucket + x.is_new_bucket,
    room_left     = CAST(CASE x.is_new_bucket WHEN 1 THEN 1.0 ELSE r.room_left END - t.size AS decimal(5,2))
  FROM atable t
  INNER JOIN rec r ON r.value = t.value + 1
  CROSS APPLY (
    SELECT CAST(CASE WHEN t.size > r.room_left THEN 1 ELSE 0 END AS bit)
  ) x (is_new_bucket)
)
SELECT
  id,
  value,
  size,
  bucket
FROM rec
ORDER BY value DESC
;

Nota: esta consulta supone que la valuecolumna consta de valores únicos sin espacios. Si ese no es el caso, deberá introducir una columna de clasificación calculada en función del orden descendente valuey utilizarla en el CTE recursivo en lugar de valueunir la parte recursiva con el ancla.

Puede encontrar una demostración de SQL Fiddle para esta consulta aquí .

Andriy M
fuente
Esto es mucho más corto que lo que escribí. Buen trabajo. ¿Hay alguna razón por la que cuentes la habitación que queda en el cubo en lugar de contar?
Nick Chammas
Sí, la hay, aunque no estoy seguro si tiene mucho sentido para la versión que terminé publicando aquí. De todos modos, la razón fue que parecía más fácil / más natural comparar un solo valor con un único valor ( sizecon room_left) en lugar de comparar un solo valor con una expresión ( 1con running_size+ size). Al principio no usé una is_new_bucketbandera sino varias CASE WHEN t.size > r.room_left ...("varias" porque también estaba calculando (y devolviendo) el tamaño total, pero luego pensé en contra de eso por simplicidad), así que pensé que sería más elegante de esa manera.
Andriy M