Come posso scrivere una query per finestre che somma una colonna per creare bucket discreti?


11

Ho una tabella che include una colonna di valori decimali, come questa:

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

Quello che devo realizzare è un po 'difficile da descrivere, quindi per favore abbi pazienza. Quello che sto cercando di fare è creare un valore aggregato della sizecolonna che aumenta di 1 ogni volta che le righe precedenti si sommano a 1, in ordine decrescente secondo value. Il risultato sarebbe simile al seguente:

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

Il mio ingenuo primo tentativo è stato quello di mantenere una corsa SUMe poi CEILINGquel valore, tuttavia non gestisce il caso in cui alcuni record sizefiniscono per contribuire al totale di due secchi separati. L'esempio seguente potrebbe chiarire questo:

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

Come puoi vedere, se dovessi semplicemente utilizzare CEILINGil crude_sumrecord n. 8, verrebbe assegnato al bucket 2. Ciò è causato dalla sizedivisione dei record n. 5 e n. 8 su due bucket. Invece, la soluzione ideale è reimpostare la somma ogni volta che raggiunge 1, che quindi incrementa la bucketcolonna e inizia una nuova SUMoperazione a partire dal sizevalore del record corrente. Poiché l'ordine dei record è importante per questa operazione, ho incluso la valuecolonna, che deve essere ordinata in ordine decrescente.

I miei tentativi iniziali hanno comportato l'esecuzione di più passaggi sui dati, una volta per eseguire l' SUMoperazione, ancora una volta a CEILINGquello, ecc. Ecco un esempio di ciò che ho fatto per creare la crude_sumcolonna:

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

Che è stato usato in UPDATEun'operazione per inserire il valore in una tabella con cui lavorare in seguito.

Modifica: mi piacerebbe fare un altro tentativo di spiegarlo, quindi ecco qui. Immagina che ogni record sia un oggetto fisico. A quell'elemento è associato un valore e una dimensione fisica inferiore a uno. Ho una serie di secchi con una capacità di volume esattamente di 1 e ho bisogno di determinare quanti di questi secchi avrò bisogno e quale secchio entra ogni articolo in base al valore dell'articolo, ordinati dal più alto al più basso.

Un oggetto fisico non può esistere in due posti contemporaneamente, quindi deve trovarsi in un secchio o nell'altro. Questo è il motivo per cui non riesco a fare una CEILINGsoluzione total + in esecuzione , perché ciò consentirebbe ai record di contribuire con le loro dimensioni a due bucket.


Dovresti aggiungere il tuo SQL per chiarire che cosa ha incluso il tuo tentativo iniziale.
mdahlman,

Stai per aggregare i dati in base al bucket che stai elaborando o il numero di bucket è la risposta finale che stai cercando?
Jon Seigel,

2
Ack. Probabilmente andrei con un'app sul lato client poiché supporterà un migliore streaming dei record rispetto a un loop cursore che recupera una riga alla volta. Penso che fintanto che tutti gli aggiornamenti vengono eseguiti in batch, dovrebbe funzionare abbastanza bene.
Jon Seigel,

1
Come hanno già detto gli altri, la necessità di entrare in gioco distinct_countcomplica le cose. Aaron Bertrand ha un ottimo riepilogo delle opzioni su SQL Server per questo tipo di lavori con finestre. Ho usato il metodo "aggiornamento stravagante" per calcolare distinct_sum, che puoi vedere qui su SQL Fiddle , ma questo non è affidabile.
Nick Chammas,

1
@JonSeigel Dovremmo notare che il problema di posizionare elementi X in un numero minimo di bucket non può essere risolto in modo efficiente utilizzando un algoritmo riga per riga del linguaggio SQL. Ad esempio, gli oggetti di dimensioni 0,7; 0,8; 0,3 avranno bisogno di 2 secchi, ma se ordinati per ID avranno bisogno di 3 secchi.
Stoleg,

Risposte:


9

Non sono sicuro del tipo di prestazioni che stai cercando, ma se CLR o l'app esterna non sono un'opzione, è sufficiente un cursore. Sul mio vecchio laptop riesco a superare le 1.000.000 di righe in circa 100 secondi usando la seguente soluzione. La cosa bella è che si ridimensiona in modo lineare, quindi dovrei guardare circa 20 minuti per scorrere l'intera cosa. Con un server decente sarai più veloce, ma non un ordine di grandezza, quindi ci vorranno ancora diversi minuti per completare questo. Se questo è un processo unico, probabilmente puoi permetterti la lentezza. Se è necessario eseguirlo regolarmente come report o simili, è possibile che si desideri archiviare i valori nella stessa tabella e aggiornarli quando vengono aggiunte nuove righe, ad esempio in un trigger.

Comunque, ecco il codice:

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;

Rilascia e ricrea la tabella MyTable, la riempie con 1000000 righe e poi va al lavoro.

Il cursore copia ogni riga in una tabella temporanea durante l'esecuzione dei calcoli. Alla fine la selezione restituisce i risultati calcolati. Potresti essere un po 'più veloce se non copi i dati ma esegui un aggiornamento sul posto.

Se si dispone dell'opzione per eseguire l'aggiornamento a SQL 2012, è possibile esaminare i nuovi aggregati di finestre mobili supportati da spool di finestra, che dovrebbero offrire prestazioni migliori.

In una nota a margine, se hai un assembly installato con permesso_set = sicuro, puoi fare più cose cattive su un server con T-SQL standard che con l'assemblaggio, quindi continuerei a lavorare per rimuovere quella barriera - Hai un buon uso caso qui in cui CLR potrebbe davvero aiutarti.


Ho accettato questo a causa della facilità con cui è stato implementato e della facilità con cui posso cambiarlo ed eseguirne il debug in un secondo momento in caso di necessità. Anche la risposta di NickChammas è corretta e probabilmente funziona in modo più efficiente, quindi credo che sia una questione di preferenza per chiunque si trovi ad affrontare un problema simile.
Zikes

9

In assenza delle nuove funzioni di windowing in SQL Server 2012, è possibile realizzare finestre complesse con l'uso di CTE ricorsivi. Mi chiedo quanto questo funzionerà contro milioni di file.

La seguente soluzione copre tutti i casi che hai descritto. Puoi vederlo in azione qui su 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
;

Ora fai un respiro profondo. Ci sono due CTE chiave qui, ciascuno preceduto da un breve commento. Il resto sono solo CTE "cleanup", ad esempio, per estrarre le righe giuste dopo averle classificate.

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

Questa soluzione presuppone che si idtratti di una sequenza gapless. In caso contrario, dovrai generare la tua sequenza gapless aggiungendo all'inizio un CTE aggiuntivo che numera le righe ROW_NUMBER()secondo l'ordine desiderato (ad es ROW_NUMBER() OVER (ORDER BY value DESC).).

Francamente, questo è abbastanza dettagliato.


1
Questa soluzione non sembra affrontare il caso in cui una riga potrebbe contribuire con le sue dimensioni a più bucket. Una somma variabile è abbastanza semplice, ma ho bisogno di quella somma per reimpostare ogni volta che arriva a 1. Vedi l'ultima tabella di esempio nella mia domanda e confronta crude_sumcon distinct_sume le loro bucketcolonne associate per vedere cosa intendo.
Zikes

2
@Zikes: ho risolto questo caso con la mia soluzione aggiornata.
Nick Chammas,

Sembra che dovrebbe funzionare ora. Lavorerò per integrarlo nel mio database per provarlo.
Zikes

@Zikes - Solo curioso, come si comportano le varie soluzioni pubblicate qui rispetto al tuo set di dati di grandi dimensioni? Immagino che Andriy sia il più veloce.
Nick Chammas,

5

Questa sembra una soluzione sciocca e probabilmente non si ridimensionerà bene, quindi prova attentamente se la usi. Dato che il problema principale deriva dallo "spazio" rimasto nel bucket, ho prima dovuto creare un record di riempimento per l'unione nei dati.

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


1
+1 Penso che questo abbia potenziale se ci sono indici appropriati.
Jon Seigel,

3

Quella che segue è un'altra soluzione CTE ricorsiva, anche se direi che è più semplice del suggerimento di @ Nick . In realtà è più vicino al cursore di @ Sebastian , solo io ho usato le differenze di corsa invece di eseguire i totali. (All'inizio ho persino pensato che la risposta di @ Nick sarebbe stata sulla falsariga di ciò che sto suggerendo qui, ed è dopo aver appreso che la sua era in realtà una domanda molto diversa che ho deciso di offrire la mia.)

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: questa query presuppone che la valuecolonna sia costituita da valori univoci senza spazi vuoti. In caso contrario, è necessario introdurre una colonna di classificazione calcolata in base all'ordine decrescente di valuee usarla nel CTE ricorsivo invece di valueunire la parte ricorsiva con l'ancora.

Una demo di SQL Fiddle per questa query è disponibile qui .


Questo è molto più breve di quello che ho scritto. Bel lavoro. C'è qualche motivo per cui conti alla rovescia la stanza rimasta nel secchio piuttosto che contare?
Nick Chammas,

Sì, c'è, non sono sicuro che abbia molto senso per la versione che ho finito per pubblicare qui, però. Comunque, la ragione era che sembrava più facile / più naturale confrontare un singolo valore con un singolo valore ( sizecon room_left) invece di confrontare un singolo valore con un'espressione ( 1con running_size+ size). All'inizio non ho usato una is_new_bucketbandiera ma diverse CASE WHEN t.size > r.room_left ...("diverse" perché stavo anche calcolando (e restituendo) la dimensione totale, ma poi ho pensato contro di essa per motivi di semplicità), quindi ho pensato che sarebbe stato più elegante quel modo.
Andriy M,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.