Защо оборотът падна?
Накратко: Проект за анализ на данни от въпроса до доклада: почистване, графики, проверка за шум, разбиване на спада на оборота по брой поръчки, категория, ден и канал и модел за загубата с интервал.
Проблем
„Защо септември е толкова слаб?“
Управителят е видял отчета: оборотът за септември 2026 е 7 128,80 €, а за август 8 812,70 €. Това е 19,1% надолу. Той има три идеи на масата: да спре рекламата, защото „не работи“, да смени доставчика на млечните или да пусне отстъпки.
Всяка идея е отговор на различна причина. Ако спадът е шум, не правиш нищо. Ако е от продукт, гледаш доставчика. Ако хората купуват по-малко, мислиш за цените. Затова въпросът на анализа не е „колко е спадът“, а „къде е спадът, за да знаем какво да поправим“.
В този проект минаваш целия път от курса: данните, почистването, графиката, проверката за шум, разбиването, модела и доклада. Числата са от годината поръчки на магазина, а отговорът не е там, където очакват идеите на управителя.
Идеята
Данните и почистването
Две таблици: year_orders.csv (поръчка, клиент, време, канал, състояние) и year_items.csv (редовете на поръчките). Стойността на поръчката е сборът от редовете ѝ. Отказаните поръчки не са оборот:
Поръчките са 11 116, а без 657-те отказани остават 10 459. Преди да смяташ, провери почистването: всяка поръчка има стойност (0 липсващи), най-малката е 0,80 €, а дните са 365, от 1.11.2025 до 31.10.2026. Оборотът за годината е 111 186,20 €, същото число като в модула „Подреждане на данни“.
new, paid и shipped са поръчки от последната седмица, които още пътуват. Броиш ги: платени са или ще бъдат. Колоната channel казва откъде е дошла поръчката: сайтът (web), приложението (app) или телефонът (phone).
Напиши почистването сам. orders['ordered_at'] да е дата, items['revenue'] оборотът на всеки ред, orders['value'] стойността на всяка поръчка, а sold поръчките без отказаните.
Решение
import pandas as pd
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
sold = orders[orders['status'] != 'cancelled']
print(len(sold), round(sold['value'].sum(), 2))В sold остават 10 459 поръчки с оборот 111 186,20 €. Ако забравиш филтъра, вътре са и 657-те отказани и всеки месец изглежда по-силен, отколкото е. Ако събереш само unit_price, губиш бройките: два хляба се броят като един.
Идеята
Първо графиката
Преди да търсиш причина, виж спада в контекста на годината:
Септември е 7 128,80 € срещу 8 812,70 € през август: −19,1%. Октомври е 9 920,40 €, значи спадът не продължава. Графиката показва и друго: декември е над 15 000 €, а януари и февруари също са ниски. Септември е спад насред нормални месеци, а не част от сезон.
Септември има 30 дни, а август 31. Дали това не обяснява част от спада? Запиши в per_day оборота на ден за всеки месец, а в change промяната на оборота на ден от август към септември в проценти.
Решение
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Оборотът е без отказаните поръчки.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
daily = sold.groupby('day')['value'].sum()
monthly = sold.groupby('month')['value'].sum()
per_day = monthly / sold.groupby('month')['day'].nunique()
change = (per_day['2026-09'] / per_day['2026-08'] - 1) * 100
print(per_day.round(2))
print(round(change, 1))На ден августът носи 284,28 €, а септември 237,63 €: −16,4%. Един ден по-малко обяснява около 3 пункта от 19,1%, а останалото е истински спад. Делението на 30 за всички месеци греши: февруари има 28 дни и изглежда по-слаб, отколкото е.
Идеята
Шум ли е?
Месеците винаги се различават малко. Преди да търсиш причина, провери дали спадът е по-голям от обичайния шум. Сравняваш дните: 31 дни на август срещу 30 на септември, с пермутационния тест от урока за хипотезите. Отдолу е промяната на оборота на ден от месец на месец:
Септемврийският ден носи средно с 46,65 € по-малко. От 10 000 разбърквания на дните само 74 дават поне толкова голяма разлика: p = 0,0074. Нормалните месеци от март до август се движат между −6,5% и +5,8%, а септември е −16,4%. Февруари (+16,8%) е връщането след празниците, а октомври (+34,7%) е връщането след септември.
Спадът не е шум. Има смисъл да търсиш причина.
Идеята
Разбий спада
Оборотът е брой поръчки по стойност на поръчка. Първото разбиване е кое от двете пада:
Поръчките падат от 854 на 701 (−17,9%), а средната поръчка е почти същата: 10,32 € и 10,17 €. Кошницата не е по-малка, поръчките са по-малко. Отстъпките не са отговорът.
Може ли да е продукт, например млечните на новия доставчик? Оборотът по категории:
Пада всяка категория, от −9,0% (плодове и зеленчуци) до −34,3% (напитки). Подаръчната карта е +14,3%, но това са 160 € срещу 140 € през август, няколко карти. Ако виновникът беше един продукт, другите категории щяха да са нормални. Спадът е навсякъде: не е продуктът.
Дали спадът не е в един ден от седмицата, например магазинът не е работил в четвъртък? Запиши в by_wd средния оборот на ден за всеки ден от седмицата (редове 0–6) за август и септември (колони), и колона change_% с промяната в проценти.
Решение
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Оборотът е без отказаните поръчки.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
daily = sold.groupby('day')['value'].sum()
monthly = sold.groupby('month')['value'].sum()
two = daily.loc['2026-08':'2026-09']
by_wd = two.groupby([two.index.dayofweek, two.index.strftime('%Y-%m')]).mean().unstack()
by_wd['change_%'] = (by_wd['2026-09'] / by_wd['2026-08'] - 1) * 100
print(by_wd.round(1))Средният оборот пада във всеки от седемте дни: от −4,0% в понеделник до −41,0% в четвъртък. Няма ден, който да е „изчезнал“: спадът е общ. Със sum вместо mean сряда излиза +18,0%, но само защото септември има 5 сряди, а август 4. При дни с различен брой сравняваш средното.
Идеята
Каналът
Остана една колона, която още не си гледал: channel. Оборотът по месец и канал:
Ето го. Сайтът (web) е 4 517,00 € през август и 4 660,80 € през септември. Телефонът (phone) е 999,70 € и 1 028,70 €. Те са нормални. Приложението (app) пада от 3 296,00 € на 1 439,30 € и се връща на 3 755,40 € през октомври.
Спадът на приложението е 1 856,70 €, повече от целия спад на магазина (1 683,90 €): другите два канала дори леко растат. Затова всяка категория и всеки ден падат заедно: приложението продава всичко, всеки ден.
Колко от оборота идва от приложението в обикновен месец? Запиши в share дела на app в оборота на всеки месец, в проценти.
Решение
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Оборотът е без отказаните поръчки.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
table = sold.pivot_table(index='month', columns='channel', values='value', aggfunc='sum')
share = table['app'] / table.sum(axis=1) * 100
print(share.round(1))В нормалните месеци приложението носи между 34,0% и 41,8% от оборота. През септември носи 20,2%, около половината, а през октомври пак 37,9%. Дялът е по-добро число от сумата: той не се влияе от сезона и от броя дни, защото те местят всички канали заедно.
Идеята
Кога точно?
„Септември“ е грубо. Екипът на приложението ще пита за дата. Поръчките от приложението ден по ден, с плъзгащото средно за 7 дни, и дните около двата края:
Линията пада в началото на септември и се връща в началото на октомври. Таблицата показва краищата. На 2.09 приложението има 9 поръчки, на 3.09 само 3, а на 5.09 нула, докато сайтът има 25 и 15. В края: 6 поръчки на 1.10, 8 на 2.10 и 12 на 3.10.
Плъзгащото средно размазва краищата с по няколко дни, затова датите се търсят в дните. Началото е ясно: 3.09. Краят е по-мътен: 2.10 вече е наполовина нормален, а от 3.10 приложението е като преди. Спадът е от 3.09 до 2.10, с точност до ден.
Намери датите с правило, а не на око. start е първият ден от 1.08.2026 нататък, в който приложението има под 20% от поръчките за деня. back е първият ден след start, в който поръчките от приложението са поне колкото медианата на нормалните дни (до 31.08).
Решение
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Оборотът е без отказаните поръчки.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
counts = sold.pivot_table(index='day', columns='channel', values='id', aggfunc='count', fill_value=0)
share = counts['app'] / counts.sum(axis=1)
normal = counts.loc[:'2026-08-31', 'app']
start = share.loc['2026-08-01':][share.loc['2026-08-01':] < 0.2].index[0]
after = counts.loc[start:, 'app']
back = after[after >= normal.median()].index[0]
print(start.date(), back.date(), normal.median())start е 3.09.2026: на този ден приложението има 10,3% от поръчките. В 304-те дни преди септември делът е под 20% само веднъж (19.04.2026). back е 3.10.2026 с 12 поръчки: медианата на нормалните дни е 11, а от 3.09 до 2.10 нито един ден не стига дори 10. Делът за деня е по-добрият сигнал от броя: той не пада, когато целият ден е слаб.
Идеята
Колко струва спадът?
Управителят ще пита колко пари е струвало. Наивният отговор е „август минус септември“: 1 683,90 €. Но той приема, че септември трябваше да е като август. Октомври е по-силен от август, затова може и септември да е трябвало да е по-силен.
По-добро сравнение дават каналите, които работят. В нормално време приложението носи определен дял спрямо сайта и телефона. Този дял k е модел с едно число: предсказва оборота на приложението от оборота на другите два канала за същите дни. Учиш го от дните преди спада и го проверяваш върху дни, които не е виждал: октомври след спада.
На всеки 1 € от сайта и телефона приложението носи 0,641 €. За октомври след спада моделът казва 3 688,92 €, а истината е 3 614,60 €: грешка 2,1%. За дните от 3.09 до 2.10 моделът очаква 3 713,41 €, а приложението е донесло 1 292,60 €. Загубата е 2 420,81 €, с 737 € повече от наивното сравнение.
Колко е точно това? Делът k за 30 дни не е винаги един и същ. С bootstrap теглиш по 30 нормални дни, 10 000 пъти, и гледаш колко се мести загубата:
В 30 случайни нормални дни k е между 0,457 и 0,906. 95% интервал за загубата е от 1 802,46 € до 3 142,14 €. В доклада това е „около 2 400 €, между 1 800 и 3 150 €“. Оценката е груба, но и долната граница е над наивните 1 683,90 €.
По-точно, за любопитните
Моделът app = k × (web + phone) има един признак и едно число, като линейна регресия без свободен член. Проверката му е като в модула за модели: учиш го върху едни дни (преди 3.09) и го мериш върху други (след 2.10). Той приема, че каналите вървят заедно, и проверката върху октомври го потвърждава. Ако през септември клиенти на приложението бяха минали на сайта, моделът щеше да надцени загубата: щеше да брои като изгубени поръчки, които всъщност са дошли от сайта. Сайтът през септември е +3,2% спрямо август, в рамките на обичайното движение, затова повечето изгубени поръчки не са отишли другаде.
Колко поръчки е изгубило приложението? Направи същия модел за броя поръчки: k е app / (web + phone) в дните преди 3.09, expected е колко поръчки от приложението щеше да има от 3.09 до 2.10, actual колко е имало, а lost_pct каква част от очакваните са изгубени, в проценти.
Решение
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Оборотът е без отказаните поръчки.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
counts = sold.pivot_table(index='day', columns='channel', values='id', aggfunc='count', fill_value=0)
counts['rest'] = counts['web'] + counts['phone']
day = counts.index
before = day < '2026-09-03'
drop = (day >= '2026-09-03') & (day <= '2026-10-02')
k = counts.loc[before, 'app'].sum() / counts.loc[before, 'rest'].sum()
expected = k * counts.loc[drop, 'rest'].sum()
actual = counts.loc[drop, 'app'].sum()
lost_pct = (expected - actual) / expected * 100
print(round(expected, 1), actual, round(lost_pct, 1))Приложението щеше да има около 368,6 поръчки, а има 126. Изгубени са 65,8%, около две от всеки три. Ако умножиш k по поръчките на самото приложение, моделът предсказва счупения канал от самия него и не вижда загубата.
Идеята
Докладът
Анализът свършва с разказ, който някой може да прочете за минута. Три изречения, една графика, границите.
- Какво: оборотът за септември е 19,1% под август, защото от 3.09 до 2.10 приложението е имало около две трети по-малко поръчки; сайтът и телефонът са нормални, а от 3.10 и приложението е нормално.
- Колко: загубата е около 2 400 € (между 1 800 и 3 150 €), около 240 поръчки.
- Какво да направим: да проверим какво се е променило в приложението около 3.09 (обновяване, кошница, плащане) и да сложим сигнал, когато делът на един канал падне.
Границите. Данните казват кога и къде: в приложението, от 3.09. Те не казват защо. Обновяване на приложението около 3.09 е хипотеза, а не факт: проверяват я екипът на приложението, логовете на кошницата и грешките на плащането за тази дата. Може да е и нещо друго: магазинът на приложенията, срок на сертификат, промяна в платежния доставчик. Графиката не различава тези причини. Затова докладът казва „проверете“, а не „това беше“.
Рекламата, доставчикът и цените не са причината. Нито една от трите идеи на управителя не би върнала оборота.
Упражнение
Сигнал за следващия път
Спадът е продължил месец, защото никой не е гледал канала. Правилото за сигнал трябва да хване спада бързо и да мълчи в нормално време. Сигнал, който вика всяка седмица, никой не гледа.
Направи сигнала. week е броят поръчки по канал за последните 7 дни (rolling(7).sum()), app_share делът на app в тях, а alerts дните, в които app_share е под 0.25. Колко тревоги има преди 3.09 и кога е първата?
Решение
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Оборотът е без отказаните поръчки.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
counts = sold.pivot_table(index='day', columns='channel', values='id', aggfunc='count', fill_value=0)
week = counts.rolling(7).sum()
app_share = week['app'] / week[['web', 'app', 'phone']].sum(axis=1)
alerts = app_share[app_share < 0.25]
print(len(alerts.loc[:'2026-09-02']), alerts.index[0].date())
print(round(app_share.loc[:'2026-09-02'].min(), 3))Преди 3.09 няма нито една тревога: делът за 7 дни е най-малко 0,308 (на 15.06.2026). Първата тревога е на 6.09, три дни след началото, вместо след месец. С дела за всеки ден поотделно щеше да има 11 фалшиви тревоги, а с праг 0,35 цели 42 дни с тревога. Прагът се избира от нормалните дни: малко под най-ниското, което те стигат.
Идеята
Какво следва
Курсът свършва тук, а работата с данни продължава. Накъде:
- Бележници. Направи този проект в JupyterLab, на своя компютър: клетка по клетка, с изхода под всяка. Бележникът е и докладът: код, графики и текст на едно място.
- По-големи данни. pandas държи всичко в паметта и работи на едно ядро. Polars има същите идеи (групи, съединяване, дати), но ползва всички ядра и чете файлове, по-големи от паметта:
pl.read_csv('year_orders.csv').group_by('channel').len()брои поръчките по канал. - Табла. Сигналът от упражнението е добре да е на табло, което се обновява само. Streamlit и Dash правят уеб табло от Python скрипт, с графики и филтри.
- Графики. seaborn и plotly са слоеве над идеите от matplotlib: seaborn дава статистически графики с един ред, а plotly интерактивни, с увеличаване и подсказки.
- Бази данни. В истински магазин поръчките са в база, а не в CSV.
pd.read_sqlчете резултата от SQL заявка направо в DataFrame. SQL се учи в курса Бази данни и SQL.
Пътят остава същият: въпросът, данните, почистването, разглеждането, изводът с границите му и разказът.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Доверителен интервал · Проверка на хипотеза · Корелация и причина · Модел: учене от примери
- Задачи по темата: Проект: защо оборотът падна