Materiál k cvičení DBM1 týden #5, sezona 2023/2024
Z předchozího cvičení je připravená posloupnost operací, která vytvoří DuckDB databázi obsahující dimenzní tabulku popisující hry v Stormgate a faktovou tabulku o hráčích. Data jsou získána z CSV souboru.
💾 Pro referenci je zde Java projekt, který by měl odpovídat stavu po cvičení #4.
Dále je připraveno prostředí pro dotazování se do vytvořené databáze.
Pro ukázku využití databáze k analytickým účelům je připraveno několik komentovaných SQL dotazů.
Základní technikou je využití agregací, které umožňují seskupit záznamy podle nějakého klíče a nad seskupenými záznamy provést výpočet. Například může být zajímavé rozčlenit hry podle serveru, na kterém byly odehrány, a zjistit počty her na jednotlivých serverech.
-- #1
SELECT server, COUNT(*) AS c
FROM matches
GROUP BY server
ORDER BY c DESC
;
V dotazu byla použita klauzule GROUP BY pro seskupení záznamů podle atributu server. V tento moment jsou jednotlivé záznamy z tabulky matches rozděleny do skupin podle hodnoty atributu server. Nad každou skupinou je následně proveden výpočet, který je definován v klauzuli SELECT. V tomto případě je to funkce COUNT(*), která počítá počet záznamů v každé skupině.
V SELECT je vždy možné mít pouze atribut použitý v GROUP BY a ostatní atributy musí být součástí agregace, díky které z množiny N hodnot vznikne hodnota pouze jedna.
Poměrně často bude potřeba použít i dimenzní tabulky společně s faktovou. V řešeném případě je v dimenzní tabulce players informace o posledním známém jménu hráče.
※ Herní jméno lze měnit a používaný INSERT OR REPLACE INTO players vždy přepisuje původní jméno novým.
Pro vypsání počtu odehraných her jednotlivých hráčů a jejich aktuální jméno lze uvažovat o následujícím dotazu:
-- #2a
SELECT p.id, p.name, COUNT(*) AS c
FROM matches m JOIN players p ON m.player1_id = p.id
GROUP BY p.id, p.name
ORDER BY c DESC
;
Databáze sice nabídne zdánlivě správné řešení, nicméně je potřeba uvědomit si význam player1_* a player2_* attributů. Jelikož v JOIN klauzuli jsou spojeny tabulky skrze player1_id, je výsledek omezen pouze na hráče, kteří byli uváděni ve hře jako hráč na první pozici (což je zcela náhodné).
Pro zohlednění této znalosti o datasetu je možné přistoupit k několika alternativním přístupům. Pro tento dotaz lze upravit spojovací podmínku, aby propojila oba hráče (#2b níže), nebo lze využít funkce UNION pro spojení dvou výsledků do jednoho.
-- #2b
SELECT p.id, p.name, COUNT(*) AS c
FROM matches m JOIN players p ON m.player1_id = p.id OR m.player2_id = p.id
GROUP BY p.id, p.name
ORDER BY c DESC
;
Obě varianty značně komplikují dotazování. Pro zjednodušení bude použit trik, který by v klasické provozní databázi nebyl vhodný kvůli duplikování dat, ale u datových skladů si lze podobné excesy s denormalizací schématu a zavedením redundantních dat dovolit.
Pro každý zápas bude vytvořen extra záznam v tabulce matches, ve kterém budou prohozeny data o hráči na pozici 1 a 2. Tedy hodnoty z player1_* budou v player2_* a naopak. Tím se změní sémantika atributů tím, že player1 bude vždy hráč zájmu a player2 jeho protihráč.
Naznačené řešení lze implementovat úpravou insertMatch metody v DuckDBOperations třídě.
try (PreparedStatement pstmt = conn.prepareStatement(insertSQL)) {
pstmt.setInt(1, match.duration);
pstmt.setString(2, match.server);
pstmt.setString(3, match.leaderboard);
pstmt.setString(4, match.match_id);
pstmt.setString(5, match.created_at);
pstmt.setString(6, match.player1_id);
pstmt.setString(7, match.player1_result);
pstmt.setString(8, match.player1_race);
pstmt.setDouble(9, match.player1_mmr);
pstmt.setInt(10, match.player1_ping);
pstmt.setString(11, match.player2_id);
pstmt.setString(12, match.player2_result);
pstmt.setString(13, match.player2_race);
pstmt.setDouble(14, match.player2_mmr);
pstmt.setInt(15, match.player2_ping);
pstmt.executeUpdate();
pstmt.setInt(1, match.duration);
pstmt.setString(2, match.server);
pstmt.setString(3, match.leaderboard);
pstmt.setString(4, match.match_id + "-swap");
pstmt.setString(5, match.created_at);
pstmt.setString(11, match.player1_id);
pstmt.setString(12, match.player1_result);
pstmt.setString(13, match.player1_race);
pstmt.setDouble(14, match.player1_mmr);
pstmt.setInt(15, match.player1_ping);
pstmt.setString(6, match.player2_id);
pstmt.setString(7, match.player2_result);
pstmt.setString(8, match.player2_race);
pstmt.setDouble(9, match.player2_mmr);
pstmt.setInt(10, match.player2_ping);
pstmt.executeUpdate();
}
Všimněte si přečíslování indexů parametrů v druhé polovině kódu a úpravy match_id v druhém volání pstmt.setInt(4, match.match_id + "-swap"). Jinak by došlo ke kolizi klíčů a tedy přepsání prvního záznamu.
Po této úpravě dotaz #2a vrátí již očekávané správné výsledky.
V některých případech bude potřeba provést vlastní výpočet metriky, která není přímo dostupná v tabulce. Například může být zajímavé zjistit, jaký byl rozdíl v MMR mezi hráči v jednotlivých hrách, což může být použito pro posouzení kvality párovacího algoritmu.
※ MMR (matchmaking rating) je hodnota sloužící pro popsání dovednostní úrovně hráče založená na 📚 Elo systému
※ Párovací algoritmus [v 1v1 hrách] se snaží namapovat vhodné protihráče z množiny momentálně aktivních hráčů. Jedná se o optimalizační problém, který se snaží minimalizovat rozdíl v MMR, dobu čekání, vzdálenost od serveru a případně další faktory.
-- #3
SELECT
ABS(player1_mmr - player2_mmr)
ROUND(ABS(player1_mmr - player2_mmr)*2,-2)/2,
FLOOR(ABS(player1_mmr - player2_mmr)/50)*50,
FROM matches m
;
Dotaz #3 demonstruje, jak lze vyjádřit rozdíl v MMR u jednotlivých zápasů a zároveň i dva způsoby, jak přistupovat k diskretizaci reálných hodnot. V tomto případě je ukázáno zaokrouhlení na nejbližší padesátku a zaokrouhlení na nižší padesátku. V praxi tedy jde o seskupení záznamů do intervalů o šířce 50 s tím rozdílem, že první generuje intervaly 0-25, 25-75, 75-125, ... zatímco druhý 0-50, 50-100, 100-150, ...
Takto diskretizovanou hodnotu lze uplatnit v seskupující klauzuli následovně:
-- #4
SELECT
FLOOR(ABS(player1_mmr - player2_mmr)/50)*50,
COUNT(*) AS c
FROM matches m
GROUP BY 1
ORDER BY 1
;
Všimněte si použití GROUP BY 1, což značí, že se seskupuje podle prvního výrazu v SELECT klauzuli. Podobně lze použít i ORDER BY 1. Tento způsob značně zjednodušuje psaní dotazů, ale pro pochopení může být účelnější psát výrazy plně, případně použít alias.
DuckDB nabízí několik speciálních funkcí, které mohou být užitečné pro datovou analýzu. Například můžeme chtít u každého hráče pozorovat, s jak kvalitními hráči hrál (viz diskretizovaný rozdíl v MMR výše). Pro tento účel by se hodilo vypočítat histogram rozdílů v MMR, případně stanovit statistický modus této hodnoty.
-- #5
SELECT
p.id,
mode(FLOOR(ABS(player1_mmr - player2_mmr)/50)*50),
histogram(FLOOR(ABS(player1_mmr - player2_mmr)/50)*50)
FROM matches m JOIN players p ON m.player1_id = p.id
GROUP BY p.id
;
Funkce mode vrací nejčastěji se vyskytující hodnotu v daném sloupci. Funkce histogram vrací mapu hodnot a jejich četností. Výsledek je tedy vhodný pro další zpracování v aplikaci.
※ Ani jedna z těchto funkcí není standardní součástí SQL a je specifická pro DuckDB.
Další zajímavou funkcí je argmin a argmax, které vrací hodnotu z jiného sloupce, která odpovídá extrémní hodnotě v daném sloupci. Tímto například můžeme získat informaci o zápase, ve kterém byl největší rozdíl v MMR nebo jiná zajímavá extrémní hodnota sledované metriky. V dotazu #6 je řešen výběr serveru s nejlepším a nejhorším pingem pro daného hráče. Tuto informaci lze například použít k odhadu regionu, ze kterého hráč pochází.
-- #6
SELECT p.id,
round(avg(m.player1_ping)),
argmin(server, m.player1_ping),
argmax(server, m.player1_ping),
FROM matches m JOIN players p ON m.player1_id = p.id
GROUP BY p.id
;