Модуль I · IT Skills (Cloud Services & Documents) · тема 6, урок 1 з 2 · 120 хвилин
Сьогодні ти пишеш build_dash.py. Цей скрипт бере сирі
відповіді з твоєї форми і перетворює їх на п'ять чисел. Завтра
ці числа покаже дашборд.
У темі 2 ти зібрав ~/data/responses.csv. Це сирі дані —
такі, як їх записав сервер, без жодної обробки.
Відкрий цей файл, і побачиш безлад. Кілька клітинок порожні. Два рядки
однакові. Хтось написав, що робить домашку 999 хвилин. А
Grist, grist і GRIST лежать як три
різні відповіді, хоча інструмент один і той самий.
Дашборд не вміє з цим працювати. Йому потрібні готові числа. Між сирим CSV і готовим числом стоїть скрипт — і саме він сьогодні головний герой.
csv.DictReader і писати
data.json через json.dump.Слова ETL, KPI і викид поки нічого не означають. Розберемо їх по черзі, кожне після конкретного прикладу.
| Що | Де | Чому саме там |
|---|---|---|
Пишеш і налагоджуєш build_dash.py |
у себе на комп'ютері, в редакторі | зручно правити, видно помилки |
| Запускаєш скрипт | на сервері, у своєму акаунті | там лежить ~/data/responses.csv, який по HTTP не віддається |
bin/build_dash.py, dash/data.json, README.md |
по FTP у кабінет | це легкий текст — сервер такі файли приймає |
python3 доступний у твоєму акаунті,
скрипт запускається від твого імені й бачить тільки твої теки. Чужі
~/data/ для нього закриті так само, як і для тебе.| Етап | Хв | Що робиш |
|---|---|---|
| Два дашборди | 10 | кажеш, на яке питання відповідає кожен |
| ETL і чому не «на льоту» | 20 | розкладаєш свої дані на три кроки |
| Чистка даних і симулятор | 25 | проганяєш конвеєр покроково, крутиш перемикач викиду |
| KPI і конструктор | 20 | формулюєш 4 питання і отримуєш код під кожне |
| Практика: скрипт | 40 | пишеш build_dash.py, отримуєш data.json |
| Перевірка чисел | 5 | звіряєш свій підрахунок із підрахунком сервера |
| Разом | 120 |
Не назва програми, а спосіб розкласти роботу на три кроки, кожен з яких можна перевірити окремо.
Уяви, що готуєш вечерю. Спершу приносиш продукти з магазину. Потім миєш, чистиш і ріжеш. І аж тоді ставиш готове на стіл. Три різні дії й три різні місця: ніхто не чистить картоплю в тарілці гостя.
З даними так само — теж три кроки. Їхні англійські назви скоротили до перших літер, і вийшло слово ETL.
Extract — узяти сирі дані звідти, де вони лежать. Відкрити файл, прочитати рядки, перетворити текст на записи. На цьому кроці нічого не рахують і нічого не виправляють: завдання — просто дістати.
Transform — почистити й порахувати. Викинути сміття, звести різні написання до одного, вирішити, що робити з порожніми клітинками, і вже потім рахувати показники.
Load — покласти результат туди, звідки його читає дашборд. У нашому
випадку це один файл ~/www/dash/data.json.
responses.csv вони будуть свої.Спокуса зробити все однією функцією велика: відкрив файл, тут-таки порахував, тут-таки намалював. Так теж працює — рівно до першої зміни.
| Ситуація | Усе в одному місці | Розділені E · T · L |
|---|---|---|
| Той самий графік потрібен на другій сторінці | копіюєш увесь код рахунку | друга сторінка читає той самий data.json |
| Джерело переїхало з CSV у базу | переписуєш усе | переписуєш тільки Extract |
| Порахували неправильно | шукаєш помилку серед верстки | дивишся data.json: число видно очима |
| Дані ще не зібрані | сторінка не малюється взагалі | сторінка малює вчорашній data.json |
Головне: між кроками з'являється видимий проміжний результат. Помилку видно на тому кроці, де вона сталася, а не в кінці, коли графік «якийсь дивний».
Ти написав одну функцію: вона читає CSV, тут-таки рахує середнє і одразу малює графік у браузері. Через тиждень той самий графік треба поставити ще на сторінку класу. Що піде не так?
Найчастіше питання на цьому уроці: «а навіщо взагалі проміжний файл, хай браузер прочитає CSV і порахує». Є три відповіді, і кожна сама по собі достатня.
Дашборд дивляться між справами: зайшов, побачив, пішов. Якщо сторінка спершу тягне весь CSV, а потім рахує медіану, людина бачить білий екран. Скрипт рахує один раз на сервері — сторінка тільки читає результат.
Сирі дані живі: кожні кілька хвилин приходить нова відповідь. Уяви, що дашборд рахує на льоту. Ти дивишся на нього з телефона, сусід — з ноутбука. У вас на екранах різні числа, і поговорити про них ви вже не можете.
Тому скрипт робить знімок: рахує один раз і кладе результат
у data.json. Усередині є мітка часу
generated_at — видно, о котрій знімок зроблено. Сперечаються
саме про нього.
responses.csv лежить у теці ~/data/.
По HTTP ця тека не віддається: з інтернету її не відкрити ніяк.
Тримає цю межу число 750 — код прав на теку. Читають
його по одній цифрі, зліва направо. Перша цифра — про тебе, власника
теки. Друга — про твою групу. Третя — про всіх решту.
7 означає «власник може все»: заходити, читати, створювати файли. 5 — «група може зайти й прочитати, але не міняти». 0 — «решта не може нічого». Вебсервер працює саме від «решти». Тому він і не має шансів віддати цей файл комусь у браузер.
Якщо змусити браузер читати CSV, файл доведеться покласти
в ~/www/. А це означає віддати сирі відповіді
однокласників усьому інтернету. Агрегат у data.json
віддавати можна: там числа, а не люди.
data.json не має бути жодного поля, за яким можна впізнати
конкретну людину: ні імені, ні session_id, ні тексту вільної
відповіді. Тільки згорнуті числа й категорії.Однокласник зробив дашборд, який читає responses.csv прямо
в браузері й рахує медіану при кожному відкритті. Файл виріс до 40 тисяч
рядків. Що тут найгірше?
Дані, зібрані від людей, ніколи не бувають чистими. Чистка — не косметика, а місце, де ти приймаєш рішення, і кожне з них видно в підсумковому числі.
Хтось не відповів на питання про час. У CSV це порожня клітинка. Що з нею робити — не технічне питання, а змістовне: різні рішення дають різні числа і різні висловлювання про клас.
| Рішення | Що ти цим стверджуєш | Коли доречно |
|---|---|---|
| Викинути запис цілком | «цієї людини для нас не існує» | майже ніколи: ти губиш і всі інші її відповіді |
| Не рахувати в цій метриці, але зберегти запис | «ця людина відповіла, але не на це питання» | звичайний вибір, якщо пропусків небагато |
| Замінити нулем | «вона витрачає 0 хвилин» | тільки якщо порожньо справді означає нуль |
| Замінити середнім | «вона така сама, як усі» | у школі — не варто: середнє стає ще більш «середнім» |
Правильний за замовчуванням варіант — другий. І обов'язково винести
в data.json число rows_used: скільки записів
реально пішло в розрахунок.
У 6 із 40 відповідей поле «хвилини на домашку» порожнє. Ти вирішив підставити туди нулі, щоб скрипт не падав. Що станеться із середнім?
Дублікат — це не «два однакові числа». Дублікат — це один і той самий факт, записаний двічі: людина двічі натиснула «Надіслати», або форму перезавантажили, або сервер повторив запит.
Як його виявити: шукаєш записи, у яких збігається ключ, що мав би бути
унікальним. У твоїй формі це session_id.
А от якщо збігається лише набір відповідей, а session_id
різні — це двоє різних людей, які просто відповіли однаково.
Викидати їх не можна.
Перша колонка — session_id. uniq -d
показує тільки ті значення, що трапилися більше одного разу. Порожній вивід —
дублікатів немає.
У CSV два однакові рядки поспіль: та сама секунда, той самий
session_id, ті самі відповіді. Далі в файлі ще два однакові
рядки, але з різними session_id. Що з цим робити?
Викид — значення, яке різко випадає з решти. Найчастіше це не злодійство. Це жарт («999 годин»). Або помилка одиниць: «2» замість «120», бо людина писала години. Або просто промах по клавіші.
Одного викиду досить, щоб зламати середнє. Не «трохи змістити» — саме зламати: середнє почне показувати число, якого немає ні в кого.
Вишикуй клас за зростом. Той, хто стоїть точно посередині шеренги, — це медіана. Прийде в клас баскетболіст на два метри — він просто стане скраю, а посередині лишиться той самий учень.
Із середнім інакше. Його рахують так: складають усі зрости й ділять на кількість людей. Ті два метри піднімуть число одразу для всіх.
З хвилинами на домашку те саме. Кожне значення тягне середнє на себе, а величезне тягне сильно. Медіані байдуже, наскільки велике крайнє число, — важливо лише, що воно крайнє.
Тому правило: показуй обидва. Якщо середнє й медіана близькі — розподіл рівний. Якщо різко різні — щось перекошує картину, і це саме по собі новина.
minutes <= 180). Напиши в README.md, яку межу
ти взяв і чому. Межа, взята зі стелі й нікому не сказана, — це вже підгонка.Середній час на домашку у класі — 47 хвилин, а медіана 25. Що це означає і хто зіпсував середнє?
Цю пастку ти вже бачив в уроці 3. Grist, grist,
GRIST і Grist з пробілом — це чотири
різні значення для комп'ютера. Для людини — одне.
Лікується двома викликами. .strip() прибирає пробіли
з країв, .lower() зводить регістр.
Ось 12 рядків із сирими проблемами. Тисни кнопки по порядку і дивись, що саме змінюється на кожному кроці. Підсвічені рядки — ті, до яких скрипт щось зробив.
Мітка generated_at у прикладі фіксована, щоб файл
не мінявся від перезавантаження сторінки. Твій скрипт має ставити реальний
час запуску.
Після чистки лишилося 10 записів із 12 прочитаних. Що обов'язково має
потрапити в data.json разом із метриками?
KPI — key performance indicator, ключовий показник. Ключове слово тут не «показник», а «ключовий»: він має відповідати на питання, яке хтось справді ставить.
Уяви стрілку бензину в машині. На неї дивляться не заради цифри, а щоб вирішити: заїжджати на заправку чи їхати далі. Це й є ключовий показник — число, від якого залежить чиясь дія.
Перевірка проста. Назви показник уголос і спитай себе: яке рішення зміниться, якщо це число буде іншим? Якщо жодне — це не KPI, це просто цифра на екрані.
| Хто дивиться | Його питання | KPI, що відповідає |
|---|---|---|
| Учитель | Чи встигає клас із домашкою? | медіана хвилин на домашку |
| Староста | Чи всі здали відповіді? | частка тих, хто відповів, від списку класу |
| Ти | Чи росте кількість відповідей день у день? | відповіді за днями (часовий ряд) |
| Учень | Чи я один такий повільний? | моє значення поруч із медіаною |
Це найчастіша помилка шкільних дашбордів. Кількість — скільки штук. Частка — скільки штук зі скількох можливих. Кількість без знаменника нічого не означає.
«Відповіли 18 людей» — це багато чи мало? Якщо в класі 28 — це 64%, нормально. Якщо опитування було на паралель зі 120 учнів — це 15%, і за такими даними висновків про паралель робити не можна.
На дашборді написано великими цифрами: «Відповідей: 34». Учителька питає: «це багато?». Чому показник поганий?
Метрика — те, що ми міряємо числом: кількість, середнє, медіана, частка. Вимір — розріз, у якому дивимось на метрику: за днями, за класом, за інструментом.
Один KPI = одна метрика + нуль або один вимір. «Медіана хвилин» — метрика без виміру, одне число. «Медіана хвилин за днями» — та сама метрика в розрізі днів, уже часовий ряд. Плутанина тут — головна причина, чому дашборд виходить нечитабельним: люди беруть три виміри одразу.
Ти хочеш показати, «скільки часу дев'ятикласники витрачають на домашку по днях тижня». Що тут метрика, а що вимір?
Обери метрику, вимір і умову — і подивись, який фрагмент
build_dash.py це дає і яке число виходить на даних симулятора.
Фрагмент розрахований на змінну clean — список
уже почищених записів. Викид (понад 180 хв) із розрахунку часу виключено.
Усе, що треба сьогодні, є в стандартній поставці Python 3, яка вже стоїть на сервері.
Для роботи з таблицями дорослі аналітики зазвичай докачують окрему бібліотеку — вона зветься pandas і вміє багато чого одним рядком. Сьогодні вона нам не потрібна: п'яти вбудованих інструментів вистачить на весь конвеєр. Заразом ти побачиш, що всередині таких бібліотек немає жодної магії.
csv.DictReaderВін читає перший рядок як заголовок і повертає кожен наступний рядок як
словник: {'ts': '...', 'minutes': '45', ...}. Звертатися можна
за назвою колонки, а не за номером — код лишається читабельним.
import csv
with open(SRC, newline='', encoding='utf-8-sig') as f:
rows = list(csv.DictReader(f))
print(rows[0]['minutes']) # '45' — рядок, не число!
newline='' — щоб модуль csv сам розбирався
з перенесеннями рядків і не ламався на файлі, збереженому у Windows.
encoding='utf-8-sig' — щоб з'їсти BOM, який Excel любить
дописувати на початок файлу. Без цього перша колонка називатиметься
session_id і row['session_id'] впаде з KeyError.
DictReader не падає на рядку з зайвою комою — він тихо складає
зайве під ключ None. Тож такий рядок треба ловити самому.Кожен рядок — словник. Додавати в нього свої поля можна прямо на місці:
r['date'] = r['ts'][:10]. Перші 10 символів ISO-дати —
це рівно 2026-02-02, тобто день без часу.
clean, seen = [], set()
for r in rows:
if None in r or None in r.values():
continue # поламана кількість колонок
if r['session_id'] in seen:
continue # дублікат
seen.add(r['session_id'])
r['tool'] = r['tool'].strip().lower()
r['date'] = r['ts'][:10]
m = r['minutes'].strip()
r['minutes'] = int(m) if m.isdigit() else None
clean.append(r)
m.isdigit() тут не прикраса: саме він рятує від
ValueError на порожній клітинці. int('') завжди
падає, а перевірити перед перетворенням — один рядок.
collections.CounterCounter — це словник, який сам рахує, скільки разів трапилося
кожне значення. Метод .most_common() віддає пари
«значення — скільки», відсортовані за спаданням.
from collections import Counter by_tool = Counter(r['tool'] for r in clean) print(by_tool) # Counter({'grist': 5, 'google таблиці': 2, ...}) print(by_tool.most_common(1)) # [('grist', 5)]
statisticsОбидві функції беруть список чисел. Порожній список їм не можна — буде помилка, тому перед викликом завжди перевіряй, що список не порожній.
from statistics import mean, median
vals = [r['minutes'] for r in clean
if r['minutes'] is not None and r['minutes'] <= 180]
avg = round(mean(vals), 1) if vals else None
med = median(vals) if vals else None
median для парної кількості значень повертає
півсуму двох середніх: для восьми відповідей це може бути
37.5, і це нормальне число, а не помилка.
json.dumpimport json
with open(DST, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
ensure_ascii=False лишає українські слова літерами. Без нього
"grist" не зміниться, а "таблиці" перетвориться
на "\u0442\u0430\u0431\u043b\u0438\u0446\u0456". Такий
запис браузер прочитає, а ти — ні.
indent=2 розставляє відступи. Файл стає придатним для читання
очима — а він саме для очей і потрібен: це ж контракт.
Скрипт падає з повідомленням
ValueError: invalid literal for int() with base 10: ''.
Що сталося і як лікувати?
build_dash.py#!/usr/bin/env python3 # ~/bin/build_dash.py — конвеєр ETL для дашборда import csv, json, os from collections import Counter from datetime import datetime, timezone from statistics import mean, median SRC = os.path.expanduser('~/data/responses.csv') DST = os.path.expanduser('~/www/dash/data.json') # ── EXTRACT ────────────────────────────────────── with open(SRC, newline='', encoding='utf-8-sig') as f: rows = list(csv.DictReader(f)) # ── TRANSFORM ──────────────────────────────────── clean, seen = [], set() for r in rows: if None in r or None in r.values(): continue if r['session_id'] in seen: continue seen.add(r['session_id']) r['tool'] = r['tool'].strip().lower() r['date'] = r['ts'][:10] m = r['minutes'].strip() r['minutes'] = int(m) if m.isdigit() else None clean.append(r) vals = [r['minutes'] for r in clean if r['minutes'] is not None and r['minutes'] <= 180] by_day = Counter(r['date'] for r in clean) by_tool = Counter(r['tool'] for r in clean) # ── LOAD ───────────────────────────────────────── data = { 'generated_at': datetime.now(timezone.utc).astimezone().isoformat(timespec='seconds'), 'source': {'file': os.path.basename(SRC), 'rows_read': len(rows), 'rows_used': len(clean)}, 'metrics': { 'responses_total': len(clean), 'avg_minutes': round(mean(vals), 1) if vals else None, 'median_minutes': median(vals) if vals else None, 'share_over_45': round(sum(1 for v in vals if v > 45) / len(vals), 3) if vals else None, 'top_tool': by_tool.most_common(1)[0][0] if by_tool else None, }, 'series': [{'date': d, 'responses': by_day[d]} for d in sorted(by_day)], 'by_tool': dict(by_tool.most_common()), } os.makedirs(os.path.dirname(DST), exist_ok=True) with open(DST, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) print('ok:', data['metrics']['responses_total'], 'записів →', DST)
series довести до 7+ точок. Копія цього коду
без жодної власної думки видно одразу: у README.md не буде
чого написати.data.json як контрактКонтракт — це домовленість між скриптом і сторінкою: скрипт обіцяє класти дані саме в такі поля, сторінка обіцяє читати саме звідти. Хто перший порушить — той і зламав дашборд.
avg_minutes на average — і картка на сторінці
показує undefined.median_minutes одного разу
число, а іншого разу рядок "37,5" з комою — графік упаде.
У JSON десятковий роздільник — крапка, завжди.null, а не «0» і не «—». Нуль означає
«порахували, вийшло нуль». null означає «порахувати не було
з чого». Це різні висловлювання.Ти відкрив data.json і бачиш
"top_tool": "\u0433\u0440\u0456\u0441\u0442". Сторінка показує
слово правильно, але читати файл очима неможливо. Що ти забув?
seriesУ часовому ряді дати мають бути в ISO 8601: 2026-02-02. Причина
не в красі: такий запис сортується як текст точно так само, як
хронологічно. Рік, місяць, день — від більшого до меншого, кожне поле
фіксованої ширини.
| Запис | Сортування текстом | Що вийде |
|---|---|---|
2026-01-12, 2026-02-02 |
2026-01-12 → 2026-02-02 |
правильно |
12.01, 02.02 |
02.02 → 12.01 |
лютий опинився перед січнем |
1/12/2026, 2/2/2026 |
як пощастить | ще й незрозуміло, де день, а де місяць |
У твоєму series дати записані як 02.02 і
04.02. Сортування дає правильний порядок, усе працює.
Коли це зламається?
Counter цього дня просто
не буде. Графік з'єднає понеділок із середою прямою лінією, наче нічого
не сталося. Правильно: пройти всі дати від першої до останньої й підставити
0 там, де записів немає. Це ж і найпростіший спосіб довести
series до потрібних 7+ точок.build_dash.pyКроки 1—3 і 15 — на твоєму комп'ютері, кроки 4—14 — по SSH на сервері, крок 16 — по FTP. Галочки зберігаються, сторінку можна закрити.
build_dash.py і README.md — у ~/upload/,
звідти скрипт кладеш у ~/bin/, а README.md
в ~/www/dash/. data.json нормально робить сам скрипт
на сервері; якщо ти налагоджував локально, залити його теж можна — це легкий
текст. А ось responses.csv у ~/www/ не потрапляє
ніколи.check 11Чекер запускає твій скрипт у пісочниці, підсовує йому зіпсовані дані й перераховує контрольну метрику сам. Вигадані числа не сходяться. Спроби не обмежені.
Демонстраційний режим: результат згенеровано для показу.
На сервері ця кнопка викликає POST /api/check, який запускає
check 11 від імені учня.
python3 ~/bin/build_dash.py при ньому,
і в терміналі з'являється рядок з кількістю записів;data.json і показуєш, звідки взялося
кожне з чотирьох чисел;README.md видно чотири пари «питання → метрика»,
і ти можеш пояснити, чиє це питання.Це те, що бачить учитель, коли виставляє оцінку.
| Складник | Вага | Результат |
|---|
int('') без перевірки;series як 01.02 — сортування ламається
на другому місяці;data.json потрапляють сирі відповіді з
session_id — порушення приватності агрегатів;ensure_ascii=False — файл нечитабельний для людини;session_id;data.json руками, без скрипта. Сервер
рахує кількість відповідей сам і звіряє зі здачею — розбіжність
видно першою ж спробою.Додай п'ятий KPI, який не рахується «в лоб». Наприклад, частку відповідей за останні три дні від усіх. Або різницю медіан 9A і 9B.
Опиши його в README.md парою «питання → метрика».
І поясни одним реченням, яке рішення міняється від цього числа.
Усе — з документації Python, стандартів і відкритих довідників. Перевіряти дозволено й корисно.
csv — CSV File Reading and Writing: DictReader,
restkey, вимога newline=''.
docs.python.org/3/library/csv.htmlcollections.Counter — підрахунок категорій,
most_common().
docs.python.org/3/library/collections.htmlstatistics — mean(), median()
і поведінка на парній кількості значень.
docs.python.org/3/library/statistics.htmljson — json.dump(),
ensure_ascii, indent.
docs.python.org/3/library/json.htmldatetime.isoformat() і timespec —
як отримати коректний ISO-рядок.
docs.python.org/3/library/datetime.htmlutf-8-sig — що це і навіщо при читанні файлів
з BOM.
docs.python.org/3/library/codecs.htmlПовний список із поясненнями, що звідки взято, —
у файлі urok-11-джерела.md поруч із цією сторінкою.