Asosiy qismga o'tish

Ombor modellashtirish: fakt va o'lchov

17-bob 10 kod bloki 2296 so'z

Direktorning oddiy savoli

Direktor kirib keladi va so'raydi:

— Sentabrda Yunusobodda o'rtacha yetkazish qancha vaqt oldi?

Siz jadvalni ochasiz. Ustunlar: buyurtma_id, vaqt, tuman, restoran_id, kuryer_id, mahsulotlar, summa_ming, tayyorlash_daq, yol_daq, holat.

Bir so'rov yozasiz, javob chiqadi. Tamom.

Ertasi kuni direktor yana keladi:

— Yaxshi. Endi restoran turlari bo'yicha ko'rsat. Fast-food va milliy taomlar alohida.

Siz jadvalga qaraysiz. restoran_id bor — R065. Lekin restoran turi yo'q. U qayerda? Boshqa tizimda, boshqa jadvalda.

Uchinchi kun:

— Kuryerlar tajribasi bo'yicha-chi? Yangi kelganlar sekinroqmi?

kuryer_id bor — K0789. Ishga kirgan sanasi yo'q.

Har savol yangi ustun talab qiladi. Har safar siz jadvalni kengaytirasiz. Olti oydan keyin jadvalda 60 ta ustun bo'ladi va hech kim qaysi biri nimani anglatishini bilmaydi.

Bu bob — shu muammoning yechimi haqida. Yechim ellik yildan beri ma'lum va u juda oddiy.

Ikki xil ma'lumot

Jadvalga yana bir marta qarang, lekin boshqa ko'z bilan. Ustunlar ikki turga bo'linadi:

Sodir bo'lgan voqea. buyurtma_id, vaqt, summa_ming, yol_daq — bular bitta buyurtma haqidagi o'lchovlar. Ular sodir bo'ldi va hech qachon o'zgarmaydi. Kechagi buyurtma 12.3 daqiqada yetkazilgan bo'lsa, u abadiy shunday qoladi.

Kim/nima haqidagi ma'lumot. tuman, restoran_id, kuryer_id — bular buyurtmaning o'zi emas, balki unga tegishli narsalar. Va ular o'zgaradi: restoran nomini o'zgartirishi mumkin, kuryer boshqa tumanga o'tishi mumkin.

Birinchisi — fakt (fact). Ikkinchisi — o'lchov (dimension).

python17 qator
diagramma(
    {
        "O'LCHOV\nsana": (0, 3.0, 2.2, 1.0, "saqlash"),
        "O'LCHOV\nrestoran": (0, 1.0, 2.2, 1.0, "saqlash"),
        "FAKT\nbuyurtma": (3.6, 2.0, 2.6, 1.4, "qayta"),
        "O'LCHOV\nkuryer": (7.4, 3.0, 2.2, 1.0, "saqlash"),
        "O'LCHOV\ntuman": (7.4, 1.0, 2.2, 1.0, "saqlash"),
    },
    [
        ("O'LCHOV\nsana", "FAKT\nbuyurtma"),
        ("O'LCHOV\nrestoran", "FAKT\nbuyurtma"),
        ("FAKT\nbuyurtma", "O'LCHOV\nkuryer"),
        ("FAKT\nbuyurtma", "O'LCHOV\ntuman"),
    ],
    nom="yulduz_sxema.png", kenglik=12,
    izoh="Yulduz sxemasi: o'rtada fakt, atrofida o'lchovlar.",
)

Yulduz sxemasi: o'rtada fakt, atrofida o'lchovlar.

Yulduz sxemasi: o'rtada fakt, atrofida o'lchovlar.

Rasmga qarang — u yulduzga o'xshaydi. Shuning uchun bu tuzilish yulduz sxemasi (star schema) deyiladi.

Farqni ajratishning oson yo'li

Ustunga qarab ikki savol bering:

  1. Bu son bilan hisob qilasizmi? (yig'indi, o'rtacha) → fakt
  2. Bu bo'yicha guruhlaysizmi? ("... bo'yicha ko'rsat") → o'lchov

summa_ming — yig'indisini olasiz, demak fakt. tuman — "tuman bo'yicha" deysiz, demak o'lchov.

Direktorning savollari doim bir xil shaklda keladi: "[fakt] ni [o'lchov] bo'yicha ko'rsat".

Nega ajratish kerak

Hamma narsani bitta jadvalda saqlasak bo'lmaydimi? Bo'ladi, lekin uch narxi bor.

Birinchi narx — takrorlanish. 60 ming buyurtmada restoran nomi 60 ming marta yoziladi. Restoran esa 200 ta.

Ikkinchi narx — o'zgartirish. Restoran nomini o'zgartirdi. Bitta jadvalda 300 ta qatorni tuzatasiz. Ularning bittasi o'tkazib yuborilsa, hisobotda ikkita restoran paydo bo'ladi.

Uchinchi narx — tarix buziladi. Mana eng nozigi. Kuryer sentabrda Chilonzonda ishlagan, oktabrda Yunusobodga o'tdi. Siz uning tumanini yangilaysiz. Endi sentabr hisoboti ham o'zgarib ketadi — go'yo u sentabrda ham Yunusobodda ishlagandek.

Shu uchinchi muammoning nomi bor: sekin o'zgaruvchi o'lchov (slowly changing dimension). Uni pastda o'lchaymiz.

Ma'lumotni tayyorlaymiz

python14 qator
python3 -c "
import duckdb, pandas as pd

d = pd.read_csv('../datasets/buyurtma.csv', parse_dates=['vaqt'])
con = duckdb.connect('ombor.db')
con.execute('DROP TABLE IF EXISTS xom')
con.execute('CREATE TABLE xom AS SELECT * FROM d')
print(f'xom jadval: {len(d):,} qator, {len(d.columns)} ustun')
print()
for u in ('tuman', 'restoran_id', 'kuryer_id'):
    n = con.execute(f'SELECT count(DISTINCT {u}) FROM xom').fetchone()[0]
    print(f'  {u:14s}: {n:>6,} xil qiymat, {len(d):,} marta takrorlangan')
con.close()
"
Chiqish (qaytish kodi 0, 925 ms)
xom jadval: 60,000 qator, 10 ustun

  tuman         :     10 xil qiymat, 60,000 marta takrorlangan
  restoran_id   :    259 xil qiymat, 60,000 marta takrorlangan
  kuryer_id     :    899 xil qiymat, 60,000 marta takrorlangan

Raqamlarga qarang: 200 ga yaqin restoran nomi 60 ming marta saqlanmoqda. Mana takrorlanish.

Yulduz sxemasini quramiz

shu faylni yaratingsrc/quvur/ombor.py

python41 qator
"""Yulduz sxemasi: fakt va o'lchov jadvallari."""
import duckdb

OLCHOVLAR = {
    "olchov_tuman": ("tuman", ["tuman"]),
    "olchov_restoran": ("restoran_id", ["restoran_id"]),
    "olchov_kuryer": ("kuryer_id", ["kuryer_id"]),
}


def olchov_yarat(con: duckdb.DuckDBPyConnection, nom: str, kalit: str) -> int:
    """Noyob qiymatlardan o'lchov jadvali yasaydi va surrogat kalit beradi."""
    con.execute(f"DROP TABLE IF EXISTS {nom}")
    con.execute(f"""
        CREATE TABLE {nom} AS
        SELECT row_number() OVER (ORDER BY {kalit}) AS id, {kalit}
        FROM (SELECT DISTINCT {kalit} FROM xom WHERE {kalit} IS NOT NULL)
    """)
    return con.execute(f"SELECT count(*) FROM {nom}").fetchone()[0]


def fakt_yarat(con: duckdb.DuckDBPyConnection) -> int:
    """Fakt jadvali: o'lchovlarga id bilan bog'lanadi, matn saqlamaydi."""
    con.execute("DROP TABLE IF EXISTS fakt_buyurtma")
    con.execute("""
        CREATE TABLE fakt_buyurtma AS
        SELECT
            x.buyurtma_id,
            CAST(x.vaqt AS DATE)      AS sana,
            t.id                      AS tuman_id,
            r.id                      AS restoran_id,
            k.id                      AS kuryer_id,
            x.summa_ming,
            x.tayyorlash_daq,
            x.yol_daq
        FROM xom x
        LEFT JOIN olchov_tuman    t ON t.tuman       = x.tuman
        LEFT JOIN olchov_restoran r ON r.restoran_id = x.restoran_id
        LEFT JOIN olchov_kuryer   k ON k.kuryer_id   = x.kuryer_id
    """)
    return con.execute("SELECT count(*) FROM fakt_buyurtma").fetchone()[0]
python20 qator
python3 -c "
import sys, pathlib; sys.path.insert(0, 'src')
import duckdb
from quvur.ombor import OLCHOVLAR, olchov_yarat, fakt_yarat

con = duckdb.connect('ombor.db')
for nom, (kalit, _) in OLCHOVLAR.items():
    n = olchov_yarat(con, nom, kalit)
    print(f'{nom:18s}: {n:>6,} qator')
n = fakt_yarat(con)
print(f'{\"fakt_buyurtma\":18s}: {n:>6,} qator')

def hajm(jadval):
    return con.execute(f'SELECT count(*) FROM {jadval}').fetchone()[0]

print()
con.execute('DROP TABLE IF EXISTS yassi')
con.execute('CREATE TABLE yassi AS SELECT * FROM xom')
con.close()
"
Chiqish (qaytish kodi 0, 479 ms)
olchov_tuman      :     10 qator
olchov_restoran   :    259 qator
olchov_kuryer     :    899 qator
fakt_buyurtma     : 60,000 qator

Endi hajmni solishtiramiz. Bitta yassi jadval va yulduz sxemasi — bir xil ma'lumot:

python19 qator
python3 -c "
import duckdb, pathlib, os
con = duckdb.connect('ombor.db')

def parquet_hajm(jadval):
    f = f'/tmp/{jadval}.parquet'
    con.execute(f\"COPY {jadval} TO '{f}' (FORMAT PARQUET, COMPRESSION ZSTD)\")
    return pathlib.Path(f).stat().st_size

yassi = parquet_hajm('yassi')
yulduz = parquet_hajm('fakt_buyurtma')
for t in ('olchov_tuman','olchov_restoran','olchov_kuryer'):
    yulduz += parquet_hajm(t)

print(f'yassi jadval    : {yassi/1024:>8.1f} KB')
print(f'yulduz sxemasi  : {yulduz/1024:>8.1f} KB')
print(f'tejaldi         : {(1-yulduz/yassi)*100:>8.1f}%')
con.close()
"
Chiqish (qaytish kodi 0, 170 ms)
yassi jadval    :    794.2 KB
yulduz sxemasi  :    517.1 KB
tejaldi         :     34.9%

Yulduz sxemasi doim kichik bo'lmaydi

Bu yerda tejash sezilarli chiqdi, lekin bu kafolat emas — natija ustunlarning turiga bog'liq. Bizda tuman qisqa matn, restoran_id esa R065 ko'rinishidagi 4 belgili satr.

Agar o'lchovda uzun matn bo'lsa (restoran to'liq nomi, manzili, telefoni), tejash ancha katta bo'ladi. Aksincha — agar barcha ustunlar allaqachon raqam bo'lsa, tejash deyarli nolga tushadi, chunki Parquet takroriy qiymatlarni o'zi ham siqadi.

Yulduz sxemasining asosiy foydasi — joy tejash emas, balki tartib va o'zgartirish qulayligi. Joy tejalsa — bonus.

So'rov yozish: oldin va keyin

Direktorning birinchi savoliga javob beramiz.

python29 qator
python3 -c "
import duckdb, time
con = duckdb.connect('ombor.db')

def olch(sql, n=5):
    con.execute(sql).fetchall()
    v = []
    for _ in range(n):
        t0 = time.perf_counter()
        con.execute(sql).fetchall()
        v.append((time.perf_counter()-t0)*1000)
    return min(v)

yassi_sql = '''
    SELECT tuman, avg(yol_daq) FROM yassi
    WHERE tuman = 'Yunusobod' GROUP BY tuman
'''
yulduz_sql = '''
    SELECT t.tuman, avg(f.yol_daq)
    FROM fakt_buyurtma f JOIN olchov_tuman t ON t.id = f.tuman_id
    WHERE t.tuman = 'Yunusobod' GROUP BY t.tuman
'''
a, b = olch(yassi_sql), olch(yulduz_sql)
print(f'yassi jadval  : {a:6.2f} ms')
print(f'yulduz (join) : {b:6.2f} ms')
print()
print(f'natija bir xilmi: {con.execute(yassi_sql).fetchall()[0][1] == con.execute(yulduz_sql).fetchall()[0][1]}')
con.close()
"
Chiqish (qaytish kodi 0, 281 ms)
yassi jadval  :   0.80 ms
yulduz (join) :   0.85 ms

natija bir xilmi: True

Yulduz sxemasida JOIN qo'shildi, ya'ni ish ko'paydi. Bu — to'lov. Nimaning evaziga?

Endi direktorning ikkinchi savolini eslang: "restoran turlari bo'yicha". Yassi jadvalda buning uchun 60 ming qatorning hammasini tuzatish kerak edi. Yulduz sxemasida esa:

python26 qator
python3 -c "
import duckdb, random
con = duckdb.connect('ombor.db')

# restoran turini QO'SHAMIZ - faqat o'lchov jadvaliga
random.seed(42)
con.execute('ALTER TABLE olchov_restoran ADD COLUMN IF NOT EXISTS turi TEXT')
con.execute(\"\"\"UPDATE olchov_restoran
                SET turi = CASE WHEN id % 3 = 0 THEN 'fast-food'
                                WHEN id % 3 = 1 THEN 'milliy'
                                ELSE 'kafe' END\"\"\")
n = con.execute('SELECT count(*) FROM olchov_restoran').fetchone()[0]
print(f'o\\'zgartirilgan qatorlar: {n:,} (o\\'lchov jadvalida)')
print(f'fakt jadvali tegilmadi  : 60,000 qator o\\'zgarmadi')
print()

r = con.execute('''
    SELECT r.turi, count(*) AS buyurtma, round(avg(f.yol_daq),2) AS ort_yol
    FROM fakt_buyurtma f JOIN olchov_restoran r ON r.id = f.restoran_id
    GROUP BY r.turi ORDER BY buyurtma DESC
''').fetchall()
print(f'{\"turi\":12s} {\"buyurtma\":>10s} {\"ort_yol\":>9s}')
for t, b, y in r:
    print(f'{t:12s} {b:>10,d} {y:>9.2f}')
con.close()
"
Chiqish (qaytish kodi 0, 243 ms)
o'zgartirilgan qatorlar: 259 (o'lchov jadvalida)
fakt jadvali tegilmadi  : 60,000 qator o'zgarmadi

turi           buyurtma   ort_yol
milliy           20,278     17.18
fast-food        20,051     17.08
kafe             19,671     17.10

Mana to'lovning javobi. Yangi xususiyat 200 qatorlik o'lchov jadvaliga qo'shildi, 60 ming qatorlik fakt jadvali umuman tegilmadi. Va endi direktorning savoliga javob bor.

Sekin o'zgaruvchi o'lchov

Eng nozik mavzuga keldik, va u bir qarashda zerikarli ko'rinadi. Ishoning — bu bobning eng qimmat qismi.

Kuryer K0789 sentabrda Chilonzorda ishlagan. Oktabrda Yunusobodga o'tdi.

Siz uning yozuvini yangilaysiz: tuman = 'Yunusobod'. Mantiqiy, to'g'rimi? Endi ma'lumot haqiqatga mos.

Endi direktor keladi va so'raydi: — Sentabrda qaysi tumanda yetkazish sekin edi?

Siz hisobot chiqarasiz. Va hisobotda K0789 ning sentabr buyurtmalari Yunusobod ostida chiqadi — chunki jadvalda endi shunday yozilgan.

Sentabr hisoboti o'zgarib ketdi. Siz o'tmishni tahrirlab qo'ydingiz.

Diqqat qiling, hech kim hisobotga tegmadi. Siz shunchaki bitta ustunni yangiladingiz. Lekin o'sha ustun o'n minglab eski buyurtmaga bog'langan edi.

Bu xatoni topish deyarli imkonsiz: hisobot ishlaydi, xato bermaydi, raqamlar oqilona ko'rinadi. Faqat ular noto'g'ri.

Muammoning rasmiy nomi bor: sekin o'zgaruvchi o'lchov (slowly changing dimension, SCD). "Sekin" — chunki kuryer har kuni tuman almashtirmaydi, yiliga bir marta. Aynan shuning uchun u sezilmaydi.

Uch xil javob bor:

python9 qator
import pandas as pd
show(pd.DataFrame([
    {"Turi": "1-tur (Type 1)", "Nima qiladi": "Eski qiymat ustiga yozadi",
     "Tarix": "Yo'qoladi", "Qachon": "Xato tuzatish"},
    {"Turi": "2-tur (Type 2)", "Nima qiladi": "Yangi qator qo'shadi, eskisini yopadi",
     "Tarix": "Saqlanadi", "Qachon": "Haqiqiy o'zgarish"},
    {"Turi": "3-tur (Type 3)", "Nima qiladi": "'oldingi_qiymat' ustuni qo'shadi",
     "Tarix": "Faqat bitta qadam", "Qachon": "Kamdan-kam"},
]))
TuriNima qiladiTarixQachon
1-tur (Type 1)Eski qiymat ustiga yozadiYo'qoladiXato tuzatish
2-tur (Type 2)Yangi qator qo'shadi, eskisini yopadiSaqlanadiHaqiqiy o'zgarish
3-tur (Type 3)'oldingi_qiymat' ustuni qo'shadiFaqat bitta qadamKamdan-kam

Farqni o'lchaymiz. Kuryer K0789 sentabrda Chilonzorda ishlagan, keyin Yunusobodga o'tdi.

python42 qator
python3 -c "
import duckdb
con = duckdb.connect('ombor.db')

con.execute('DROP TABLE IF EXISTS kuryer_t1')
con.execute(\"\"\"CREATE TABLE kuryer_t1 AS
    SELECT id, kuryer_id, 'Chilonzor' AS tuman FROM olchov_kuryer\"\"\")

con.execute('DROP TABLE IF EXISTS kuryer_t2')
con.execute(\"\"\"CREATE TABLE kuryer_t2 AS
    SELECT id, kuryer_id, 'Chilonzor' AS tuman,
           DATE '2026-01-01' AS boshi, DATE '9999-12-31' AS oxiri, TRUE AS joriy
    FROM olchov_kuryer\"\"\")

print('--- O\\'ZGARISHDAN OLDIN ---')
for j in ('kuryer_t1', 'kuryer_t2'):
    n = con.execute(f\"SELECT count(*) FROM {j} WHERE kuryer_id='K0789'\").fetchone()[0]
    print(f'{j}: {n} qator')

# K0789 Yunusobodga o'tdi
con.execute(\"UPDATE kuryer_t1 SET tuman='Yunusobod' WHERE kuryer_id='K0789'\")

con.execute(\"\"\"UPDATE kuryer_t2 SET oxiri=DATE '2026-09-15', joriy=FALSE
               WHERE kuryer_id='K0789' AND joriy\"\"\")
con.execute(\"\"\"INSERT INTO kuryer_t2
    SELECT id, kuryer_id, 'Yunusobod', DATE '2026-09-16', DATE '9999-12-31', TRUE
    FROM olchov_kuryer WHERE kuryer_id='K0789'\"\"\")

print()
print('--- O\\'ZGARISHDAN KEYIN ---')
r1 = con.execute(\"SELECT tuman FROM kuryer_t1 WHERE kuryer_id='K0789'\").fetchall()
print(f'1-tur: {len(r1)} qator -> {[x[0] for x in r1]}')
print('  savol: u SENTABRDA qayerda ishlagan? javob yo\\'q, tarix o\\'chdi')

r2 = con.execute(\"\"\"SELECT tuman, boshi, oxiri FROM kuryer_t2
                    WHERE kuryer_id='K0789' ORDER BY boshi\"\"\").fetchall()
print(f'2-tur: {len(r2)} qator')
for t, b, o in r2:
    print(f'  {t:12s} {b} .. {o}')
print('  savol: u SENTABRDA qayerda ishlagan? -> 1-qator, Chilonzor')
con.close()
"
Chiqish (qaytish kodi 0, 121 ms)
--- O'ZGARISHDAN OLDIN ---
kuryer_t1: 1 qator
kuryer_t2: 1 qator

--- O'ZGARISHDAN KEYIN ---
1-tur: 1 qator -> ['Yunusobod']
  savol: u SENTABRDA qayerda ishlagan? javob yo'q, tarix o'chdi
2-tur: 2 qator
  Chilonzor    2026-01-01 .. 2026-09-15
  Yunusobod    2026-09-16 .. 9999-12-31
  savol: u SENTABRDA qayerda ishlagan? -> 1-qator, Chilonzor

Natija aniq. 1-turda savolga javob yo'q — tarix ustiga yozilgan. 2-turda esa har qiymatning amal qilish muddati saqlangan, shuning uchun "o'sha paytda qanday edi" degan savol javobsiz qolmaydi.

2-tur bepul emas

Har o'zgarish yangi qator qo'shadi. Tez o'zgaradigan o'lchov (masalan mijozning oxirgi faolligi) 2-tur bilan saqlansa, o'lchov jadvali fakt jadvalidan kattaroq bo'lib ketishi mumkin.

Qoida: 2-tur ni faqat tarixi haqiqatan kerak bo'lgan ustunlarga qo'llang. Qolganiga 1-tur.

Va eng muhimi: so'rovda joriy = TRUE shartini unutmang. Aks holda har kuryer bir necha marta hisoblanadi va raqamlaringiz jim ravishda shishadi.

Tozalash

python10 qator
python3 -c "
import duckdb, pathlib
con = duckdb.connect('ombor.db')
con.execute('DROP TABLE IF EXISTS yassi')
con.close()
for f in pathlib.Path('/tmp').glob('*.parquet'):
    f.unlink(missing_ok=True)
print('vaqtinchalik fayllar tozalandi')
print('ombor.db saqlanadi (18-bobda kerak)')
"
Chiqish (qaytish kodi 0, 89 ms)
vaqtinchalik fayllar tozalandi
ombor.db saqlanadi (18-bobda kerak)

Xulosa

Tushuncha Asosiy fikr
Fakt Sodir bo'lgan voqea; o'lchanadi, o'zgarmaydi
O'lchov Kim/nima haqida; guruhlash uchun, o'zgaradi
Ajratish mezoni Hisob qilasizmi → fakt; guruhlaysizmi → o'lchov
Yulduz sxemasi O'rtada fakt, atrofida o'lchovlar
Surrogat kalit Matn o'rniga raqam; joy va tartib
Asosiy foyda Joy emas — o'zgartirish qulayligi
Yangi xususiyat 200 qatorlik o'lchovga qo'shiladi, faktga emas
1-tur Ustiga yozadi, tarix yo'qoladi
2-tur Yangi qator, muddat bilan; tarix saqlanadi
2-tur tuzog'i joriy = TRUE ni unutsangiz raqamlar shishadi

Talaba ishi

  1. olchov_restoran ga "manzil" va "telefon" ustunlarini qo'shing (uzun matn). Yulduz sxemasining hajm afzalligi qanday o'zgardi?
  2. Sana o'lchovi (olchov_sana) yarating: yil, oy, hafta kuni, dam olish kunimi. "Dam olish kunlari yetkazish sekinroqmi?" degan savolga javob bering.
  3. kuryer_t2 dan foydalanib sentabr va oktabr hisobotini alohida chiqaring. Farq bormi?
  4. 2-turdagi so'rovda joriy = TRUE ni ataylab tushirib qoldiring. Buyurtmalar soni qancha oshib ketdi?

Bu bobni bepul o'qidingiz.

Kitob o'rgatadi, lekin sizning kodingiz nega ishlamaganini ayta olmaydi. Kursda uy vazifangiz tekshiriladi.

Telegramda yozish Narxlar