有限のコラボレーション距離で行に一意の値を割り当てるためのソリューション


9

次のコードを作成して入力できるテーブルがあります。

CREATE TABLE dbo.Example(GroupKey int NOT NULL, RecordKey varchar(12) NOT NULL);
ALTER TABLE dbo.Example
    ADD CONSTRAINT iExample PRIMARY KEY CLUSTERED(GroupKey ASC, RecordKey ASC);
INSERT INTO dbo.Example(GroupKey, RecordKey)
VALUES (1, 'Archimedes'), (1, 'Newton'), (1, 'Euler'), (2, 'Euler'), (2, 'Gauss'),
       (3, 'Gauss'), (3, 'Poincaré'), (4, 'Ramanujan'), (5, 'Neumann'),
       (5, 'Grothendieck'), (6, 'Grothendieck'), (6, 'Tao');

別の行に基づいて有限のコラボレーション距離を持つすべての行RecordKeyに、一意の値を割り当てたいのですが、一意の値がどのようなデータ型であるかは関係ありません。

私が求めているものを満たす正しい結果セットは、次のクエリで生成できます。

SELECT 1 AS SupergroupKey, GroupKey, RecordKey
FROM dbo.Example
WHERE GroupKey IN(1, 2, 3)
UNION ALL
SELECT 2 AS SupergroupKey, GroupKey, RecordKey
FROM dbo.Example
WHERE GroupKey = 4
UNION ALL
SELECT 3 AS SupergroupKey, GroupKey, RecordKey
FROM dbo.Example
WHERE GroupKey IN(5, 6)
ORDER BY SupergroupKey ASC, GroupKey ASC, RecordKey ASC;

私が求めていることをよりよく支援するために、GroupKeys 1〜3が同じである理由を説明しますSupergroupKey

  • GroupKey1はRecordKeyオイラーを含み、オイラーはGroupKey2に含まれます。したがって、GroupKeys 1と2は同じでなければなりませんSupergroupKey
  • GaussはGroupKeys 2と3の両方に含まれているため、それらも同じでなければなりませんSupergroupKey。これにより、GroupKeys 1–3が同じになりSupergroupKeyます。
  • GroupKeys 1–3はRecordKey残りGroupKeyのsとsを共有しないため、SupergroupKey値1が割り当てられているのはsだけです。

ソリューションは汎用的である必要があることを付け加えておきます。上記の表と結果セットは単なる例です。

補遺

ソリューションが反復的でないという要件を削除しました。私はそのような解決策を好みますが、それは不合理な制約だと思います。残念ながら、CLRベースのソリューションは使用できません。しかし、そのようなソリューションを含めたい場合は、遠慮なくしてください。私はおそらくそれを答えとして受け入れないでしょう。

私の実際のテーブルの行数は500万にもなりますが、行数が「1万」程度になる日もあります。平均してRecordKeyあたり8 秒とあたりGroupKey4 GroupKeyRecordKeyです。ソリューションは時間的に指数関数的に複雑になると思いますが、それでもソリューションに興味があります。

回答:


7

これは、パフォーマンス比較のための反復T-SQLソリューションです。

これは、スーパーグループキーを格納するためにテーブルに追加の列を追加でき、インデックスを変更できることを前提としています。

セットアップ

DROP TABLE IF EXISTS 
    dbo.Example;

CREATE TABLE dbo.Example
(
    SupergroupKey integer NOT NULL
        DEFAULT 0, 
    GroupKey integer NOT NULL, 
    RecordKey varchar(12) NOT NULL,

    CONSTRAINT iExample 
    PRIMARY KEY CLUSTERED 
        (GroupKey ASC, RecordKey ASC),

    CONSTRAINT [IX dbo.Example RecordKey, GroupKey]
    UNIQUE NONCLUSTERED (RecordKey, GroupKey),

    INDEX [IX dbo.Example SupergroupKey, GroupKey]
        (SupergroupKey ASC, GroupKey ASC)
);

INSERT dbo.Example
    (GroupKey, RecordKey)
VALUES 
    (1, 'Archimedes'), 
    (1, 'Newton'),
    (1, 'Euler'),
    (2, 'Euler'),
    (2, 'Gauss'),
    (3, 'Gauss'),
    (3, 'Poincaré'),
    (4, 'Ramanujan'),
    (5, 'Neumann'),
    (5, 'Grothendieck'),
    (6, 'Grothendieck'),
    (6, 'Tao');

現在の主キーのキーの順序を逆にできる場合は、追加の一意のインデックスは必要ありません。

概要

このソリューションのアプローチは次のとおりです。

  1. スーパーグループIDを1に設定します
  2. 番号が最も小さい未処理のグループキーを見つける
  3. 見つからない場合は、終了します
  4. 現在のグループキーですべての行にスーパーグループを設定する
  5. 現在のグループの行に関連するすべての行にスーパーグループを設定します
  6. 行が更新されなくなるまで手順5を繰り返します。
  7. 現在のスーパーグループIDをインクリメントします
  8. 手順2に進みます。

実装

インラインコメント:

-- No execution plans or rows affected messages
SET NOCOUNT ON;
SET STATISTICS XML OFF;

-- Reset all supergroups
UPDATE E
SET SupergroupKey = 0
FROM dbo.Example AS E
    WITH (TABLOCKX)
WHERE 
    SupergroupKey != 0;

DECLARE 
    @CurrentSupergroup integer = 0,
    @CurrentGroup integer = 0;

WHILE 1 = 1
BEGIN
    -- Next super group
    SET @CurrentSupergroup += 1;

    -- Find the lowest unprocessed group key
    SELECT 
        @CurrentGroup = MIN(E.GroupKey)
    FROM dbo.Example AS E
    WHERE 
        E.SupergroupKey = 0;

    -- Exit when no more unprocessed groups
    IF @CurrentGroup IS NULL BREAK;

    -- Set super group for all records in the current group
    UPDATE E
    SET E.SupergroupKey = @CurrentSupergroup
    FROM dbo.Example AS E 
    WHERE 
        E.GroupKey = @CurrentGroup;

    -- Iteratively find all groups for the super group
    WHILE 1 = 1
    BEGIN
        WITH 
            RecordKeys AS
            (
                SELECT DISTINCT
                    E.RecordKey
                FROM dbo.Example AS E
                WHERE
                    E.SupergroupKey = @CurrentSupergroup
            ),
            GroupKeys AS
            (
                SELECT DISTINCT
                    E.GroupKey
                FROM RecordKeys AS RK
                JOIN dbo.Example AS E
                    WITH (FORCESEEK)
                    ON E.RecordKey = RK.RecordKey
            )
        UPDATE E WITH (TABLOCKX)
        SET SupergroupKey = @CurrentSupergroup
        FROM GroupKeys AS GK
        JOIN dbo.Example AS E
            ON E.GroupKey = GK.GroupKey
        WHERE
            E.SupergroupKey = 0
        OPTION (RECOMPILE, QUERYTRACEON 9481); -- The original CE does better

        -- Break when no more related groups found
        IF @@ROWCOUNT = 0 BREAK;
    END;
END;

SELECT
    E.SupergroupKey,
    E.GroupKey,
    E.RecordKey
FROM dbo.Example AS E;

実行計画

キーの更新について:

計画を更新

結果

テーブルの最終的な状態は次のとおりです。

╔═══════════════╦══════════╦══════════════╗
║ SupergroupKey ║ GroupKey ║  RecordKey   ║
╠═══════════════╬══════════╬══════════════╣
║             1 ║        1 ║ Archimedes   ║
║             1 ║        1 ║ Euler        ║
║             1 ║        1 ║ Newton       ║
║             1 ║        2 ║ Euler        ║
║             1 ║        2 ║ Gauss        ║
║             1 ║        3 ║ Gauss        ║
║             1 ║        3 ║ Poincaré     ║
║             2 ║        4 ║ Ramanujan    ║
║             3 ║        5 ║ Grothendieck ║
║             3 ║        5 ║ Neumann      ║
║             3 ║        6 ║ Grothendieck ║
║             3 ║        6 ║ Tao          ║
╚═══════════════╩══════════╩══════════════╝

デモ:db <> fiddle

性能試験

Michael Greenの回答で提供されている拡張テストデータセットを使用した場合、私のラップトップでのタイミングは次のとおりです*

╔═════════════╦════════╗
║ Record Keys ║  Time  ║
╠═════════════╬════════╣
║ 10k         ║ 2s     ║
║ 100k        ║ 12s    ║
║ 1M          ║ 2m 30s ║
╚═════════════╩════════╝

* Microsoft SQL Server 2017(RTM-CU13)、Developer Edition(64ビット)、Windows 10 Pro、16GB RAM、SSD、4コアハイパースレッドi7、公称2.4GHz。


これは素晴らしい答えです。私の質問で予見されたように、「大きな日」には遅すぎます。しかし、それは私の小さい日々には素晴らしいです。250万行のテーブルで実行するには、約5時間かかりました。
basketballfan22

10

この問題は、アイテム間のリンクをたどることに関するものです。これにより、グラフとグラフ処理の領域に配置されます。具体的には、データセット全体がグラフを形成し、そのグラフのコンポーネントを探しています。これは、質問のサンプルデータのプロットで説明できます。

ここに画像の説明を入力してください

質問では、GroupKeyまたはRecordKeyをたどって、その値を共有する他の行を見つけることができると述べています。したがって、両方をグラフの頂点として扱うことができます。質問は、GroupKeys 1〜3が同じSupergroupKeyをどのように持つかを説明します。これは、細い線で結ばれた左側のクラスターとして見ることができます。この図は、元のデータによって形成された他の2つのコンポーネント(SupergroupKey)も示しています。

SQL Serverには、T-SQLに組み込まれたグラフ処理機能がいくつかあります。現時点では、これは非常に貧弱であり、この問題には役立ちません。SQL Serverには、RおよびPythonを呼び出す機能と、それらに使用できる豊富で堅牢なパッケージスイートもあります。その1つがigraphです。それは「何百万もの頂点とエッジを持つ大きなグラフの高速処理(link)」のために書かれています。

Rとigraphを使用して、ローカルテスト1で2分22秒で100万行を処理することができました。これは、現在の最良のソリューションと比較する方法です。

Record Keys     Paul White  R               
------------    ----------  --------
Per question    15ms        ~220ms
100             80ms        ~270ms
1,000           250ms       430ms
10,000          1.4s        1.7s
100,000         14s         14s
1M              2m29        2m22s
1M              n/a         1m40    process only, no display

The first column is the number of distinct RecordKey values. The number of rows
in the table will be 8 x this number.

100万行を処理する場合、グラフの読み込みと処理、およびテーブルの更新に1m40が使用されました。SSMS結果テーブルに出力を取り込むには、42秒が必要でした。

100万行が処理されている間のタスクマネージャーの観察は、約3GBの作業メモリが必要であることを示唆しています。これは、ページングなしでこのシステムで使用できました。

再帰的なCTEアプローチに対するYpercubeの評価を確認できます。数百のレコードキーを使用して、CPUと使用可能なすべてのRAMを100%消費しました。最終的にtempdbが80GB以上になり、SPIDがクラッシュしました。

PaulgroupのテーブルとSupergroupKey列を使用したので、ソリューション間で公平な比較ができます。

何らかの理由でRはポアンカレのアクセントに異議を唱えました。プレーンな「e」に変更すると、実行できます。それは目前の問題に密接に関係していないので、私は調査しませんでした。私は解決策があると確信しています。

これがコードです

-- This captures the output from R so the base table can be updated.
drop table if exists #Results;

create table #Results
(
    Component   int         not NULL,
    Vertex      varchar(12) not NULL primary key
);


truncate table #Results;    -- facilitates re-execution

declare @Start time = sysdatetimeoffset();  -- for a 'total elapsed' calculation.

insert #Results(Component, Vertex)
exec sp_execute_external_script   
    @language = N'R',
    @input_data_1 = N'select GroupKey, RecordKey from dbo.Example',
    @script = N'
library(igraph)
df.g <- graph.data.frame(d = InputDataSet, directed = FALSE)
cpts <- components(df.g, mode = c("weak"))
OutputDataSet <- data.frame(cpts$membership)
OutputDataSet$VertexName <- V(df.g)$name
';

-- Write SuperGroupKey to the base table, as other solutions do
update e
set
    SupergroupKey = r.Component
from dbo.Example as e
inner join #Results as r
    on r.Vertex = e.RecordKey;

-- Return all rows, as other solutions do
select
    e.SupergroupKey,
    e.GroupKey,
    e.RecordKey
from dbo.Example as e;

-- Calculate the elapsed
declare @End time = sysdatetimeoffset();
select Elapse_ms = DATEDIFF(MILLISECOND, @Start, @End);

これはRコードが行うことです

  • @input_data_1 SQL ServerがテーブルからRコードにデータを転送し、それをInputDataSetと呼ばれるRデータフレームに変換する方法です。

  • library(igraph) ライブラリをR実行環境にインポートします。

  • df.g <- graph.data.frame(d = InputDataSet, directed = FALSE)データをigraphオブジェクトにロードします。グループからレコードへ、またはレコードからグループへのリンクをたどることができるため、これは無向グラフです。InputDataSetは、Rに送信されるデータセットのSQL Serverのデフォルト名です。

  • cpts <- components(df.g, mode = c("weak")) グラフを処理して、離散サブグラフ(コンポーネント)およびその他のメジャーを見つけます。

  • OutputDataSet <- data.frame(cpts$membership)SQL Serverは、Rから返されるデータフレームを予期します。そのデフォルト名はOutputDataSetです。コンポーネントは「メンバーシップ」と呼ばれるベクターに保存されます。このステートメントは、ベクターをデータフレームに変換します。

  • OutputDataSet$VertexName <- V(df.g)$nameV()は、グラフ内の頂点のベクトル-GroupKeysおよびRecordKeysのリストです。これにより、それらが出力データフレームにコピーされ、VertexNameという新しい列が作成されます。これは、SupergroupKeyを更新するためにソーステーブルと照合するために使用されるキーです。

私はRの専門家ではありません。おそらくこれは最適化できるでしょう。

テストデータ

OPのデータは検証に使用されました。スケールテストでは、次のスクリプトを使用しました。

drop table if exists Records;
drop table if exists Groups;

create table Groups(GroupKey int NOT NULL primary key);
create table Records(RecordKey varchar(12) NOT NULL primary key);
go

set nocount on;

-- Set @RecordCount to the number of distinct RecordKey values desired.
-- The number of rows in dbo.Example will be 8 * @RecordCount.
declare @RecordCount    int             = 1000000;

-- @Multiplier was determined by experiment.
-- It gives the OP's "8 RecordKeys per GroupKey and 4 GroupKeys per RecordKey"
-- and allows for clashes of the chosen random values.
declare @Multiplier     numeric(4, 2)   = 2.7;

-- The number of groups required to reproduce the OP's distribution.
declare @GroupCount     int             = FLOOR(@RecordCount * @Multiplier);


-- This is a poor man's numbers table.
insert Groups(GroupKey)
select top(@GroupCount)
    ROW_NUMBER() over (order by (select NULL))
from sys.objects as a
cross join sys.objects as b
--cross join sys.objects as c  -- include if needed


declare @c int = 0
while @c < @RecordCount
begin
    -- Can't use a set-based method since RAND() gives the same value for all rows.
    -- There are better ways to do this, but it works well enough.
    -- RecordKeys will be 10 letters, a-z.
    insert Records(RecordKey)
    select
        CHAR(97 + (26*RAND())) +
        CHAR(97 + (26*RAND())) +
        CHAR(97 + (26*RAND())) +
        CHAR(97 + (26*RAND())) +
        CHAR(97 + (26*RAND())) +
        CHAR(97 + (26*RAND())) +
        CHAR(97 + (26*RAND())) +
        CHAR(97 + (26*RAND())) +
        CHAR(97 + (26*RAND())) +
        CHAR(97 + (26*RAND()));

    set @c += 1;
end


-- Process each RecordKey in alphabetical order.
-- For each choose 8 GroupKeys to pair with it.
declare @RecordKey varchar(12) = '';
declare @Groups table (GroupKey int not null);

truncate table dbo.Example;

select top(1) @RecordKey = RecordKey 
from Records 
where RecordKey > @RecordKey 
order by RecordKey;

while @@ROWCOUNT > 0
begin
    print @Recordkey;

    delete @Groups;

    insert @Groups(GroupKey)
    select distinct C
    from
    (
        -- Hard-code * from OP's statistics
        select FLOOR(RAND() * @GroupCount)
        union all
        select FLOOR(RAND() * @GroupCount)
        union all
        select FLOOR(RAND() * @GroupCount)
        union all
        select FLOOR(RAND() * @GroupCount)
        union all
        select FLOOR(RAND() * @GroupCount)
        union all
        select FLOOR(RAND() * @GroupCount)
        union all
        select FLOOR(RAND() * @GroupCount)
        union all
        select FLOOR(RAND() * @GroupCount)
    ) as T(C);

    insert dbo.Example(GroupKey, RecordKey)
    select
        GroupKey, @RecordKey
    from @Groups;

    select top(1) @RecordKey = RecordKey 
    from Records 
    where RecordKey > @RecordKey 
    order by RecordKey;
end

-- Rebuild the indexes to have a consistent environment
alter index iExample on dbo.Example rebuild partition = all 
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, 
      ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON);


-- Check what we ended up with:
select COUNT(*) from dbo.Example;  -- Should be @RecordCount * 8
                                   -- Often a little less due to random clashes
select 
    ByGroup = AVG(C)
from
(
    select CONVERT(float, COUNT(1) over(partition by GroupKey)) 
    from dbo.Example
) as T(C);

select
    ByRecord = AVG(C)
from
(
    select CONVERT(float, COUNT(1) over(partition by RecordKey)) 
    from dbo.Example
) as T(C);

OPの定義から比率を間違った方法で取得したことに気づきました。これがタイミングに影響するとは思わない。レコードとグループは、このプロセスと対称的です。アルゴリズムにとって、それらはすべてグラフ内の単なるノードです。

データのテストでは、常に単一のコンポーネントが形成されました。これはデータが均一に分布しているためだと思います。静的な1:8の比率の代わりに生成ルーチンにハードコード化した場合比率を変化させることができれば、さらにコンポーネントが存在する可能性が高くなります。



1マシン仕様:Microsoft SQL Server 2017(RTM-CU12)、Developer Edition(64ビット)、Windows 10 Home。16 GB RAM、SSD、4コアハイパースレッドi7、公称2.8 GHz テストは、通常のシステムアクティビティ(CPU約4%)を除いて、現時点で実行されている唯一の項目でした。


6

再帰的なCTEメソッド-大きなテーブルでは恐ろしく非効率的である可能性があります。

WITH rCTE AS 
(
    -- Anchor
    SELECT 
        GroupKey, RecordKey, 
        CAST('|' + CAST(GroupKey AS VARCHAR(10)) + '|' AS VARCHAR(100)) AS GroupKeys,
        CAST('|' + CAST(RecordKey AS VARCHAR(10)) + '|' AS VARCHAR(100)) AS RecordKeys,
        1 AS lvl
    FROM Example

    UNION ALL

    -- Recursive
    SELECT
        e.GroupKey, e.RecordKey, 
        CASE WHEN r.GroupKeys NOT LIKE '%|' + CAST(e.GroupKey AS VARCHAR(10)) + '|%'
            THEN CAST(r.GroupKeys + CAST(e.GroupKey AS VARCHAR(10)) + '|' AS VARCHAR(100))
            ELSE r.GroupKeys
        END,
        CASE WHEN r.RecordKeys NOT LIKE '%|' + CAST(e.RecordKey AS VARCHAR(10)) + '|%'
            THEN CAST(r.RecordKeys + CAST(e.RecordKey AS VARCHAR(10)) + '|' AS VARCHAR(100))
            ELSE r.RecordKeys
        END,
        r.lvl + 1
    FROM rCTE AS r
         JOIN Example AS e
         ON  e.RecordKey = r.RecordKey
         AND r.GroupKeys NOT LIKE '%|' + CAST(e.GroupKey AS VARCHAR(10)) + '|%'
         -- 
         OR e.GroupKey = r.GroupKey
         AND r.RecordKeys NOT LIKE '%|' + CAST(e.RecordKey AS VARCHAR(10)) + '|%'
)
SELECT 
    ROW_NUMBER() OVER (ORDER BY GroupKeys) AS SuperGroupKey,
    GroupKeys, RecordKeys
FROM rCTE AS c
WHERE NOT EXISTS
      ( SELECT 1
        FROM rCTE AS m
        WHERE m.lvl > c.lvl
          AND m.GroupKeys LIKE '%|' + CAST(c.GroupKey AS VARCHAR(10)) + '|%'
        OR    m.lvl = c.lvl
          AND ( m.GroupKey > c.GroupKey
             OR m.GroupKey = c.GroupKey
             AND m.RecordKeys > c.RecordKeys
              )
          AND m.GroupKeys LIKE '%|' + CAST(c.GroupKey AS VARCHAR(10)) + '|%'
          AND c.GroupKeys LIKE '%|' + CAST(m.GroupKey AS VARCHAR(10)) + '|%'
      ) 
OPTION (MAXRECURSION 0) ;

dbfiddle.ukでテスト済み

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.