Wszystkie artykuły

KajoData / SQL

EDA w SQL: poznaj dane, zanim policzysz wynik

Prześledź sześć zamówień, rozpoznaj braki i powtórzenia oraz ustal, jakie warunki musi spełnić wiarygodny wynik raportu.

Kajo RudzińskiOpublikowano Aktualizacja 11 min czytania

Lupa na mechanicznym ramieniu oświetla próbki danych wysunięte z bazy

Dostajesz tabelę, piszesz SUM(amount) i masz wynik. Tyle że nadal nie wiesz, czy jeden wiersz oznacza zamówienie, pozycję zamówienia czy kolejną wersję tego samego zdarzenia. Ja od tego zaczynam eksploracyjną analizę danych, czyli EDA w SQL. Najpierw ustalam, co liczę i czego jeszcze nie rozumiem.

EDA pomaga rozpoznać strukturę, braki, rozkłady i podejrzane obserwacje. NIST opisuje ją jako podejście do poznawania danych, z dużą rolą wizualizacji. SQL jest wygodny do pierwszego profilowania i porównań grup, ale sam zestaw agregatów nie wyczerpuje eksploracji. Czasem dopiero wykres pokaże kształt, którego tabela ze średnią nie ujawnia.

Poniżej używam PostgreSQL 18. Dane są syntetyczne, małe i celowo niedoskonałe. Wyniki wyprowadzam z pokazanych rekordów oraz reguł dokumentacji. Nie przenoszę składni konkretnego silnika na wszystkie bazy pod wspólną etykietą „SQL”.

Zacznij od pytania i poziomu szczegółowości

Załóżmy, że pytanie brzmi: jaka jest wartość opłaconych zamówień w badanym okresie? Zanim dodam filtr, potrzebuję definicji „opłacone”, znaczenia kwoty i reguły rozpoznawania jednego zamówienia. Kwota może dotyczyć całego zamówienia albo jednej jego pozycji. Status może opisywać obecny stan albo zdarzenie z historii.

To jest poziom szczegółowości danych, często nazywany ziarnem tabeli. W naszym ćwiczeniu oczekujemy jednego wiersza na zamówienie. Surowe źródło może jednak naruszać tę regułę. Nie wpisuję więc unikalności identyfikatora do założeń tylko dlatego, że kolumna nazywa się order_id.

Oddzielam również okres zamówienia od okresu płatności. Jeśli raport ma dotyczyć płatności październikowych, data utworzenia zamówienia może być niewłaściwym filtrem. Tutaj pole order_date opisuje datę zamówienia i tak nazywam zakres analizy. Dodatkowy wymóg daty płatności oznaczałby potrzebę innych danych.

Sprawdź strukturę właściwym narzędziem

Dla rzeczywistej tabeli public.orders_raw mogę odczytać kolumny tak:

SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name = 'orders_raw'
ORDER BY ordinal_position;

Widok information_schema.columns pokazuje kolumny dostępne bieżącemu użytkownikowi. Brak wierszy wymaga sprawdzenia nazwy, schematu i dostępności obiektu. Nie jest dowodem, że w całej bazie nie istnieje żadna podobna tabela.

DESCRIBE i SHOW COLUMNS spotkasz w innych silnikach. Nie są tutaj uniwersalnym sposobem oglądania tabeli PostgreSQL. Również informacje o typie i dopuszczalności NULL nie zastąpią znaczenia biznesowego. Kolumna liczbowa może zawierać kwoty w różnych jednostkach, a dopuszczenie NULL nie tłumaczy przyczyny braku.

Pytam więc o walutę, sposób aktualizacji, strefę czasową, zakres historii i znaczenie korekt. Dla kwot rozważam odpowiedni typ numeric, ale sam wybór typu nie rozstrzygnie, czy wolno dodać wartości z różnych walut.

Sześć rekordów, które można prześledzić ręcznie

Zamiast zakładać dostęp do bazy firmowej, użyjmy CTE z danymi ćwiczeniowymi:

WITH orders_raw(order_id, customer_id, order_date, status, amount) AS (
    VALUES
        (101, 1, DATE '2026-10-01', 'paid',      100::numeric),
        (102, 1, DATE '2026-10-02', 'paid',       50::numeric),
        (102, 1, DATE '2026-10-02', 'paid',       50::numeric),
        (103, 2, DATE '2026-10-03', 'cancelled',   0::numeric),
        (104, NULL, DATE '2026-10-04', 'paid', NULL::numeric),
        (105, 3, DATE '2026-10-05', 'paid',      300::numeric)
)
SELECT *
FROM orders_raw
ORDER BY order_id, order_date, amount NULLS LAST;

To kompletne zapytanie tworzy źródło tylko dla swojej instrukcji. CTE nie zapisuje tabeli na później. W kolejnych przykładach zachowaj blok WITH ... AS (...) i zastąp końcowy SELECT pokazanym zapytaniem. Jeśli masz rzeczywistą tabelę o tej nazwie, możesz odpytywać ją bez CTE.

Widzę sześć wierszy i pięć różnych identyfikatorów zamówień. Identyfikator 102 występuje dwukrotnie. Zamówienie 104 nie ma klienta i kwoty, a 103 jest anulowane z kwotą zero. Każda z tych obserwacji wymaga innego pytania. Wrzucenie wszystkiego do koszyka „błędne dane” niewiele pomaga.

Policz wiersze, znane wartości i braki osobno

Zastępuję końcowy wybór danych profilem:

SELECT
    COUNT(*) AS row_count,
    COUNT(DISTINCT order_id) AS distinct_orders,
    COUNT(amount) AS known_amounts,
    COUNT(*) - COUNT(amount) AS missing_amounts,
    MIN(amount) AS min_amount,
    MAX(amount) AS max_amount,
    SUM(amount) AS total_amount,
    AVG(amount) AS avg_known_amount
FROM orders_raw;

Oczekuję sześciu wierszy, pięciu identyfikatorów, pięciu znanych kwot i jednego braku. Znane kwoty to 100, 50, 50, 0 i 300. Ich suma wynosi 500, średnia 100, minimum 0, maksimum 300. W tym miejscu są to charakterystyki surowych rekordów, jeszcze bez uzgodnionego wyniku biznesowego.

Dokumentacja agregatów PostgreSQL rozróżnia COUNT(*) i COUNT(wyrażenie). Drugi wariant pomija NULL. Podobnie AVG(amount) liczy średnią znanych kwot. Zamiana braku na zero zmieniłaby mianownik i znaczenie średniej. Taki zabieg potrzebuje konkretnej reguły, a nie odruchowego „uzupełnienia pustych komórek”.

Dla pustego wyniku COUNT zwraca zero, a między innymi SUM i AVG zwracają NULL. W raporcie odróżniam więc brak rekordów od rekordów o sumie równej zero. To dwa różne stany źródła, choć na dashboardzie łatwo nadać im identyczny wygląd.

Duplikat identyfikatora jest sygnałem do wyjaśnienia

Szukam powtórzeń klucza, który miał identyfikować zamówienie:

SELECT order_id, COUNT(*) AS occurrences
FROM orders_raw
GROUP BY order_id
HAVING COUNT(*) > 1
ORDER BY order_id;

Wynik zawiera identyfikator 102 z licznością 2. Zgodnie z regułą ćwiczenia, jeden wiersz na zamówienie, to naruszenie oczekiwania. W innym źródle dwa wiersze mogłyby oznaczać dwie pozycje zamówienia albo wersje historii. Dopiero definicja tabeli pozwala nazwać to duplikatem technicznym.

Nie naprawiam tego przez SUM(DISTINCT amount). Ta operacja usuwałaby równe kwoty, także należące do różnych legalnych zamówień. Nie wybieram też dowolnego wiersza bez sprawdzenia, czy pozostałe kolumny się zgadzają. Konflikt statusu albo kwoty wymaga reguły pierwszeństwa wersji.

W przykładzie dwa wiersze są identyczne, więc po potwierdzeniu technicznego powielenia jedna kopia 50 mogłaby zostać wyłączona z obliczeń. Nadal pozostaje pytanie o brakującą kwotę w 104. Jedna poprawka nie zamyka oceny całego zbioru.

Plansza z powtarzającymi się klockami oraz pustą komórką wymagającą wyjaśnienia

Rozkład kategorii odkrywa ukryte definicje

Sprawdzam, jakie statusy faktycznie występują:

SELECT
    status,
    COUNT(*) AS rows_in_status,
    COUNT(amount) AS known_amounts,
    SUM(amount) AS known_total
FROM orders_raw
GROUP BY status
ORDER BY status;

Dla cancelled otrzymam jeden wiersz, jedną znaną kwotę i sumę zero. Dla paid: pięć wierszy, cztery znane kwoty i sumę 500. To zestawienie pokazuje jednocześnie zakres kategorii i problem kompletności. Sam napis paid nie gwarantuje obecności kwoty.

W prawdziwym źródle przyjrzałbym się także różnicom wielkości liter i spacji, pustym ciągom oraz nieznanym kodom. Automatyczna zamiana wszystkich wariantów na jedną etykietę może być poprawna, ale potrzebuje mapowania uzgodnionego ze znaczeniem systemu. pending i brak statusu nie muszą znaczyć tego samego.

Po ustaleniu kategorii porównuję ich udział w kolejnych okresach lub źródłach. Nagły wzrost liczby braków może wskazywać zmianę eksportu. Zmiana udziału statusów może być rzeczywistym zjawiskiem operacyjnym. EDA dostarcza kierunków sprawdzenia; sama częstotliwość nie ustala przyczyny.

Granice czasu i kompletność okresu

Zakres dat w ćwiczeniu zaczyna się 1 października i kończy 5 października 2026. To nie jest pełny miesiąc. Nie nazywam sumy „sprzedażą październikową” bez dopisku o ograniczonym zakresie danych. Maksymalna data w tabeli mówi o zawartości tabeli, a nie o kompletności zasilania.

Dla października zastosowałbym półotwarty filtr:

SELECT order_id, order_date, status, amount
FROM orders_raw
WHERE order_date >= DATE '2026-10-01'
  AND order_date < DATE '2026-11-01'
ORDER BY order_date, order_id;

Dla typu date to czytelne granice miesiąca. Przy znacznikach czasu trzeba dodatkowo ustalić typ i strefę. Zapis „do ostatniego dnia włącznie” może łatwo pominąć jego godziny, jeśli ktoś porówna timestamp do samej daty o północy. Nie mieszam tych przypadków w jednym uniwersalnym przepisie.

Sprawdzam również dni bez rekordów. Brak niedzielnych zamówień może być normalny, ale brak całego dnia po awarii eksportu już wymaga wyjaśnienia. Potrzebuję kalendarza oczekiwanych dni i informacji o źródle, żeby odróżnić zero aktywności od brakującej dostawy danych.

Wysoka wartość nie jest automatycznie błędem

Kwota 300 jest największa w naszym małym zbiorze. Nie ma jednak podstaw, żeby ją usuwać. Duże zamówienie może być poprawne, a małe pozornie zwyczajne zamówienie może mieć błędną walutę. Zaczynam od reguł dopuszczalności i sprawdzenia rekordu u źródła.

Wolę prosty opis „jedna obserwacja ma kwotę 300” niż etykietę „anomalia” opartą na przypadkowym progu. Jeśli biznes określa limit kontroli, mogę przygotować listę rekordów przekraczających ten limit. Sam próg pozostaje regułą procesu, którą należy nazwać w raporcie.

Starszy przepis z granicą średnia plus lub minus trzy odchylenia standardowe bywa przydatną heurystyką, ale nie jest uniwersalnym wykrywaczem błędów. Rozkłady kwot są często asymetryczne, a przy małej próbie próg jest szczególnie trudny do interpretacji. Tutaj oglądam rekordy, porównuję grupy i dopiero potem dobieram metodę.

Relacje i korelacja wymagają właściwej jednostki

Przed połączeniem zamówień z tabelą klientów sprawdzam unikalność identyfikatora klienta po drugiej stronie. Jeśli jeden klient ma tam kilka wierszy historii, zwykły JOIN może pomnożyć kwoty. Po połączeniu porównuję liczność i sumy kontrolne z wejściem. Więcej o samym mechanizmie znajdziesz w poradniku LEFT JOIN.

Osobno identyfikuję zamówienia bez klienta i klucze bez dopasowania. To różne sytuacje: brak wartości klucza oraz wartość, której nie ma w słowniku. Zastąpienie obu etykietą „nieznany” może być wygodne do prezentacji, lecz do naprawy zasilania potrzebuję tego rozróżnienia.

PostgreSQL udostępnia corr(Y, X), ale nie liczę korelacji identyfikatora klienta z kwotą zamówienia. Numer klienta nie opisuje wielkości ilościowej w sensownym porządku. Nawet przy dwóch sensownych zmiennych trzeba sprawdzić wspólne niepuste pary, jednostkę obserwacji i wykres. Korelacja nie rozstrzyga kierunku ani przyczyny związku.

Oddziel eksplorację od naprawy i końcowej metryki

Na etapie EDA zachowuję surowe wartości i zapisuję, które reguły wymagałyby przekształcenia. Przy każdym wniosku podaję zakres: sześć rekordów, pięć identyfikatorów, jeden brak kwoty, jedno powtórzenie. Dzięki temu druga osoba może prześledzić, skąd biorą się wątpliwości.

Po usunięciu potwierdzonej technicznej kopii 50 znana suma spadłaby z 500 do 450. Wśród opłaconych zamówień pozostałyby cztery identyfikatory, z czego trzy ze znaną kwotą. Nie przedstawiam 450 jako kompletnej wartości wszystkich opłaconych zamówień, ponieważ kwota w 104 wciąż jest nieznana. To suma znanych wartości po określonej korekcie.

Taki opis jest użyteczniejszy niż pojedyncza liczba bez warunków. Pokazuje, co już wiadomo, co jest decyzją o danych i czego nadal brakuje do odpowiedzi. Rozbudowane obliczenia mają sens dopiero na tej podstawie. Przy porządkowaniu filtrów i grupowania pomoże też kolejność klauzul SQL.

Jak zamknąć pierwsze przejście przez dane

Własną notatkę analityczną buduję wokół pytań wymagających odpowiedzi, zamiast kopiować kilkanaście surowych wyników bez kontekstu. Przy powieleniu 102 potrzebuję potwierdzenia reguły importu. Przy 104 pytam, czy brak oznacza oczekiwanie na rozliczenie. Przy zakresie dat ustalam, czy otrzymałem pełną dostawę. Każde pytanie ma konkretny wpływ na przyszłą metrykę.

Odnotowuję także, kiedy odczytałem dane. Jeżeli tabela zmienia się podczas pracy, wynik pierwszej agregacji i późniejszej próbki może pochodzić z innego stanu. Dla powtarzalnego porównania potrzebny jest uzgodniony sposób odczytu tego samego zakresu. Sześć wierszy ćwiczenia jest stałe, natomiast prawdziwe źródło operacyjne może się aktualizować w trakcie analizy.

Na dużej tabeli dobieram zakres i koszt zapytań do środowiska. Próbka kilku rekordów pomaga zobaczyć formaty, ale nie dowodzi braku rzadkich błędów. Pełne liczniki mogą wymagać szerszego odczytu. Nie obiecuję przyspieszenia przez samą zmianę kolejności zapisanych kolumn; plan i koszt zależą od silnika, struktury danych i dostępnych indeksów.

Dobry wynik pierwszej eksploracji zawiera więc opis jednostki, pokrycia okresu, braków i warunków użycia danych. Jeżeli jedno z tych założeń pozostaje otwarte, pokazuję ograniczenie przy wniosku. Osoba podejmująca decyzję potrzebuje wiedzieć, czy liczba jest już gotowa do raportu, czy dopiero wskazuje kierunek dalszej pracy.

Zapisz regułę filtrowania razem z wynikiem

Filtr status = 'paid' odpowiada na pytanie o rekordy oznaczone dokładnie tym kodem. Nie rozstrzyga, czy zamówienie było kiedykolwiek opłacone ani czy później nastąpił zwrot. Jeśli źródło przechowuje wyłącznie ostatni status, historyczna odpowiedź może wymagać tabeli zdarzeń. Warto wykryć tę granicę przed przygotowaniem wykresu z wieloma miesiącami.

Podobnie customer_id IS NULL wykryje brak identyfikatora, ale nie brak całego opisu klienta. Nie dopisuję do wyniku interpretacji „klient anonimowy” bez znajomości procesu. Przyczyną może być gościnne zamówienie, opóźnione połączenie słownika albo problem importu. Każda z tych możliwości prowadzi do innego działania, mimo identycznej wartości w kolumnie.

Sprawdzam też, czy warunek nie wycina właśnie rekordów, które miały być przedmiotem kontroli. Profilowanie samych znanych kwot przez WHERE amount IS NOT NULL utrudni ocenę braków: licznik pustych wartości wyniesie wtedy zero z definicji. Najpierw opisuję pełny uzgodniony zakres, później pokazuję wersję z filtrem i wyjaśniam różnicę.

Do porównania przed i po czyszczeniu wybieram stały zestaw liczb kontrolnych. W ćwiczeniu są to liczba wierszy, liczba identyfikatorów, liczba braków i suma znanych kwot. Jeżeli korekta miała usunąć jedną kopię 102, oczekuję zmiany liczby wierszy i sumy, ale zachowania liczby różnych identyfikatorów. Takie oczekiwanie da się jasno opisać przed zmianą.

Poza SQL-em potrzebuję czasem rozmowy z właścicielem procesu. Sama tabela nie powie, czy anulowane zamówienie powinno zachować wcześniejszą kwotę, czy zostać wyzerowane. Nie próbuję wywnioskować regulaminu firmy wyłącznie z kilku rekordów. Pokazuję warianty i ich wpływ na pytanie, a odpowiedź procesową odróżniam od obserwacji w danych.

W miarę rozwoju eksploracji wracam do pierwotnej hipotezy o ziarnie. Jeśli pojawi się kolumna numeru pozycji, unikalny klucz może składać się z dwóch pól. Jeśli dane są migawkami dziennymi, potrzebna będzie także data migawki. Wtedy wcześniejsze powtórzenie identyfikatora zamówienia dostaje inne wyjaśnienie i zmienia się sposób przygotowania miary.

To dobry moment na zatrzymanie automatycznej deduplikacji. Wybranie jednej wersji z historii bez ustalonej daty odniesienia może zmienić raport w sposób, którego trudno potem bronić. Wolę oddać pierwsze rozpoznanie z konkretnym pytaniem niż usunąć potrzebne rekordy i opisać zbiór jako gotowy. Eksploracja ma zmniejszać niepewność stopniowo, z zachowaniem informacji potrzebnych do następnego kroku.

Anulowane zamówienie z kwotą zero nie zwiększa sumy, ale pozostaje w liczbie rekordów i w średniej znanych kwot całego surowego źródła. Dlatego podanie samej sumy nie wystarczy do rozpoznania, jakie obserwacje obejmowała analiza. Przy każdej metryce zachowuję opis filtra oraz jednostki liczenia.

Ćwiczenie: co zmieni się po nowym rekordzie

Dopisz na kartce syntetyczne zamówienie 106, klient 3, status paid, kwota 50, data 6 października. Przewidź nową liczbę wierszy, znanych kwot i unikalnych identyfikatorów. Wyjaśnij, dlaczego kolejne legalne zamówienie o kwocie 50 nie powinno zniknąć podczas usuwania duplikatów.

Następnie rozważ trzy wersje zamówienia 104: kwota nadal nieznana, potwierdzone zero i potwierdzona kwota 100. Zapisz, jak zmieni się opis kompletności oraz średnia znanych kwot. To ćwiczenie szybko pokazuje, dlaczego pierwsza sensowna agregacja zaczyna się od znaczenia danych. Ja właśnie tak rozumiem praktyczną EDA w SQL.

Najważniejsze w pigułce

Od czego zacząć EDA w SQL?

Ustal pytanie, poziom szczegółowości wiersza i zakres danych. Następnie sprawdź strukturę, liczności, braki, powtórzenia i rozkłady.

Czy COUNT(amount) policzy wszystkie zamówienia?

Policzy wiersze ze znaną kwotą. COUNT(*) liczy wszystkie wiersze, a liczba zamówień wymaga dodatkowo ustalenia ziarna i duplikatów.

Czy największą kwotę trzeba usunąć jako anomalię?

Sama wielkość nie dowodzi błędu. Najpierw sprawdź reguły dopuszczalności, jednostkę i rekord źródłowy.

Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.