
Для иследования я выбрала открытые данные о поездках городских велосипедов Helsinki & Espoo City Bikes. В исходных CSV для каждой поездки есть время начала/конца, станции отправления и прибытия, расстояние и длительность.

сгенерировано с использованием krea.ai// промт: young woman riding bicycle, casual clothes, backpack, clear blue sky, blue minimalistic background
Цель проекта — понять, как меняется активность в течение дня и недели, какие маршруты самые популярные, и есть ли заметные различия между буднями и выходными. Для этого я использую Pandas (очистка, группировки, pivot-таблицы) и Matplotlib (инфографика в едином стиле).
В начале работы я подобрала цветовую палитру с помощью Adobe Color. В проекте использован шрифт Tahoma.
Tahoma® by Microsoft Corporation


1-й вариант// 2-й вариант (альтернативный)
Анализ данных

сгенерировано с использованием krea.ai// промт: blue silhouette of multiple bicycles parked in a row, minimalist style, clean background, side view, high detail on wheels and handlebars
//код № 1
import os, urllib.request as ur import numpy as np import pandas as pd import matplotlib.pyplot as plt import matplotlib as mp import matplotlib.font_manager as fm
mp.rcParams.update ({ «figure.facecolor»:»#ffffff», «axes.facecolor»:»#ffffff», «savefig.facecolor»:»#ffffff», «axes.edgecolor»:»#111827», «axes.labelcolor»:»#111827», «xtick.color»:»#111827», «ytick.color»:»#111827», «text.color»:»#111827», «grid.color»:»#e5e7eb», «grid.alpha»: 1.0, «axes.grid»: True, «axes.titleweight»:"bold», })
u = «https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bslnt%2Cwght%5D.ttf" p = «Inter.ttf» try: if not os.path.exists (p): ur.urlretrieve (u, p) fm.fontManager.addfont (p) mp.rcParams[«font.family»] = «Inter» except Exception as e: print («font skip:», e)
os.makedirs («fig», exist_ok=True) os.makedirs («dat», exist_ok=True)
//код № 2
yd = 2021ms = [4,5,6,7,8,9,10]ps = []for m in ms: fn = f"dat/{yd}-{m: 02d}.csv» if not os.path.exists (fn): u = f»https://dev.hsl.fi/citybikes/od-trips-{yd}/{yd}-{m: 02d}.csv» try: print («dl», u) ur.urlretrieve (u, fn) except Exception as e: print («skip», m, e) continue ps.append (fn)print («files:», len (ps))
dl https://dev.hsl.fi/citybikes/od-trips-2021/2021-04.csv dl https://dev.hsl.fi/citybikes/od-trips-2021/2021-05.csv dl https://dev.hsl.fi/citybikes/od-trips-2021/2021-06.csv dl https://dev.hsl.fi/citybikes/od-trips-2021/2021-07.csv dl https://dev.hsl.fi/citybikes/od-trips-2021/2021-08.csv dl https://dev.hsl.fi/citybikes/od-trips-2021/2021-09.csv dl https://dev.hsl.fi/citybikes/od-trips-2021/2021-10.csv files: 7
//код № 3
ts = []for fp in ps: d = pd.read_csv (fp) d.columns = [c.strip ().lower () for c in d.columns] c = {} for k in d.columns: if k in [«departure»,"start time»,"starttime»]: c[k] = «t0» if k in [«return»,"end time»,"endtime»]: c[k] = «t1» if «departure station id» in k or k in [«departure_station_id»,"dep_station_id»]: c[k] = «i0» if «departure station name» in k or k in [«departure_station_name»,"dep_station_name»]: c[k] = «n0» if «return station id» in k or k in [«return_station_id»,"ret_station_id»]: c[k] = «i1» if «return station name» in k or k in [«return_station_name»,"ret_station_name»]: c[k] = «n1» if «covered distance» in k or «distance (m» in k or k in [«distance»,"distance_m»]: c[k] = «dm» if «duration» in k or «sec» in k or k in [«duration»,"duration_s»]: c[k] = «ds» d = d.rename (columns=c) d = d[[«t0»,"t1»,"i0»,"n0»,"i1»,"n1»,"dm»,"ds»]] ts.append (d)t = pd.concat (ts, ignore_index=True)t.head (), t.shape
(t0 t1 i0 n0 i1
0 30.04.2021T23:59:54 01.05.2021T00:08:15 16 Liisanpuistikko 2
1 30.04.2021T23:59:52 01.05.2021T00:16:16 20 Kaisaniemenpuisto 112
2 30.04.2021T23:59:52 01.05.2021T00:13:40 4 Viiskulma 33
3 30.04.2021T23:59:43 01.05.2021T00:23:22 133 Paavalinpuisto 241
4 30.04.2021T23:59:41 01.05.2021T00:16:15 20 Kaisaniemenpuisto 112
n1 dm ds
0 Laivasillankatu 1885.0 499.0 1 Rautatieläisenkatu 3359.0 984.0 2 Kauppakorkeakoulu 1633.0 824.0 3 Agronominkatu 6459.0 1418.0 4 Rautatieläisenkatu 3210.0 990.0, (4759778, 8))
Данные загрузились нормально: получилось примерно 4.76 млн поездок и 8 основных столбцов (время начала/конца, станции, расстояние, длительность). Дальше я буду чистить явные аномалии, чтобы статистика и графики были адекватными.
t[«t0»] = pd.to_datetime (t[«t0»], errors="coerce»)t[«t1»] = pd.to_datetime (t[«t1»], errors="coerce»)t = t.dropna (subset=[«t0»,"t1»,"dm»,"ds»,"i0»,"i1»])
(4241999, 12)
сгенерировано с использованием krea.ai// промт: woman sitting on bench near water, bicycle next to bench, soft lighting, calm atmosphere, blue cyanotype style
На этом шаге я убрала мусорные записи: слишком короткие/длинные поездки и нереалистичные расстояния. Потом я посчитала скорость и сделала квантильный тримминг 1–99%, чтобы выбросы не портили распределения и средние значения.
a = t.pivot_table (index="dw», columns="hr», values="ds», aggfunc="size», fill_value=0).to_numpy ()
f, ax = plt.subplots (figsize=(12,4)) im = ax.imshow (a, aspect="auto», cmap="magma»)
ax.set_title («Поездки: день недели × час (пики коммьюта)») ax.set_xlabel («Час») ax.set_ylabel («День недели») ax.set_xticks (range (0,24,2)) ax.set_xticklabels ([f"{x: 02d}» for x in range (0,24,2)]) ax.set_yticks (range (7)) ax.set_yticklabels ([«Mon»,"Tue»,"Wed»,"Thu»,"Fri»,"Sat»,"Sun»])
cb = f.colorbar (im, ax=ax, pad=0.02) cb.set_label («Количество поездок»)
mx = np.unravel_index (np.argmax (a), a.shape) ax.scatter (mx[1], mx[0], s=80, facecolors="none», edgecolors=»#e5e7eb», linewidths=2) ax.text (mx[1]+0.5, mx[0], «max», va="center»)
plt.tight_layout () plt.savefig («fig/01_heat_dw_hr.png», dpi=250) plt.show ()
Этот график показывает «ритм города»: сколько поездок происходит в каждый час и в каждый день недели. По нему легко увидеть часы пик и различия между буднями и выходными.
Что видно по моему результату: в будни явно доминирует активность днём и вечером, а максимум приходится примерно на вторник–четверг около 16–18 часов (самая светлая зона).
d = t.groupby («dt»).size ().rename («n»).to_frame () d[«r7»] = d[«n»].rolling (7, min_periods=1).mean ()
f, ax = plt.subplots (figsize=(12,4)) ax.plot (d.index, d[«n»], linewidth=1, alpha=0.55, label="daily») ax.plot (d.index, d[«r7»], linewidth=2.5, label="7d mean»)
ax.set_title («Динамика поездок по дням (сглаживание 7 дней)») ax.set_xlabel («Дата») ax.set_ylabel («Поездки») ax.legend ()
x = d[«n»].nlargest (3) for k, v in x.items (): ax.scatter (k, v, s=40) ax.text (k, v, f» {k}», va="bottom», fontsize=9)
plt.tight_layout () plt.savefig («fig/02_daily_series.png», dpi=250) plt.show ()
Здесь я смотрю динамику по датам: сколько поездок было каждый день. Сверху добавляю скользящее среднее за 7 дней, чтобы лучше увидеть общий тренд без дневного шума.
Что видно по моему результату: с апреля идёт рост к лету, затем пик в середине июня (примерно 10–22 июня). После конца июля / начала августа заметен резкий спад, и к сентябрю значения становятся существенно ниже.
f, ax = plt.subplots (figsize=(10,4)) ax.hist (t[«vk»], bins=60, density=True, alpha=0.9)
m = t[«vk»].median () ax.axvline (m, linewidth=2) ax.text (m, ax.get_ylim ()[1]*0.9, f» median={m:.1f}», rotation=90, va="top»)
ax.set_title («Распределение скорости (после тримминга 1–99%)») ax.set_xlabel («Скорость, км/ч») ax.set_ylabel («Плотность»)
plt.tight_layout () plt.savefig («fig/03_speed_hist.png», dpi=250) plt.show ()
Тут я смотрю типичную скорость поездок. Это помогает понять, насколько данные похожи на «реальную городскую езду», а не на выбросы.
Что видно по моему результату: распределение похоже на «колокол», а медианная скорость ≈ 11.8 км/ч. Основная масса поездок лежит примерно в диапазоне 8–16 км/ч.
r = (t.groupby ([«n0»,"n1»]).size () .sort_values (ascending=False) .head (15))
lb = [f"{a} → {b}» for a, b in r.index]
f, ax = plt.subplots (figsize=(10,6)) ax.barh (range (len®)[: -1], r.values[: -1]) ax.set_yticks (range (len®)[: -1]) ax.set_yticklabels (lb[: -1], fontsize=8)
ax.set_title («Топ-15 маршрутов (отправление → прибытие)») ax.set_xlabel («Поездки»)
plt.tight_layout () plt.savefig («fig/04_top_routes.png», dpi=250) plt.show ()
Тут я беру пары станций «откуда → куда» и считаю, какие маршруты встречаются чаще всего. Это показывает, где реально происходят самые популярные перемещения.
Что видно по моему результату: лидеры — маршруты около Aalto-yliopisto / Korkeakouluaukio ↔ Jämeräntaival (в обе стороны, примерно по ~14k). Похоже на стабильный «кампусный» поток.
x0, x1 = m[«x»].min (), m[«x»].max () y0, y1 = m[«y»].min (), m[«y»].max () rt = (x1-x0)/((y1-y0)+1e-9)
w = 12 h = max (3.5, w/rt)
q = np.quantile (np.abs (m[«p»].to_numpy ()), 0.98) + 1e-9 cp = np.clip (m[«p»].to_numpy (), -q, q)
sz = 6 + 0.06*np.sqrt (m[«n»].to_numpy ())
f, ax = plt.subplots (figsize=(w, h)) sc = ax.scatter (m[«x»], m[«y»], s=sz, c=cp, cmap="coolwarm», vmin=-q, vmax=q, alpha=0.9, linewidths=0)
ax.set_title («Станции: размер=нагрузка, цвет=out — in») ax.set_xlabel («lon») ax.set_ylabel («lat»)
ax.set_aspect («auto») ax.margins (0.02)
cb = f.colorbar (sc, ax=ax, pad=0.02) cb.set_label («out — in (обрезка по 98% квантилю)»)
plt.tight_layout () plt.savefig («fig/05_station_map.png», dpi=250, bbox_inches="tight», facecolor="white») plt.show ()
Эта визуализация показывает станции на карте. Размер точки — насколько станция загружена (всего отправлений+прибытий), цвет — баланс: станция больше «источник» (out > in) или «приёмник» (in > out).
t[«we»] = (t[«dw»] >= 5).astype (int) # 1=weekendg = t.groupby («we»)[«vk»].agg ([«count»,"mean»,"median»,"std»])print (g)dm = g.loc[1,"median»] — g.loc[0,"median»]print («weekend — weekday (median speed):», round (dm, 2), «km/h»)sh = (t[«dm»] < 1000).groupby (t[«we»]).mean ()print («share <1km:», sh.to_dict ())
count mean median std
we 0 3111836 11.751608 11.895652 2.892765 1 1130163 11.361181 11.502000 2.951903 weekend — weekday (median speed): -0.39 km/h share <1km: {0: 0.17064106206111118, 1: 0.14931916900482498}
На этом шаге я сравниваю будни и выходные по скорости поездок. Я считаю базовую описательную статистику (count/mean/median/std) отдельно для будних дней и для выходных, а также дополнительно смотрю долю коротких поездок (< 1 км).
сгенерировано с использованием krea.ai// промт: silhouettes of people walking and riding bicycles, reflections on wet surface, minimalistic composition, blue monochrome, calm atmosphere
Результаты анализа данных:
в будни медианная скорость ≈ 11.90 км/ч, в выходные ≈ 11.50 км/ч; разница −0.39 км/ч (в выходные чуть медленнее);
средняя скорость тоже немного ниже в выходные (11.36 против 11.75 км/ч);
доля поездок короче 1 км в выходные ниже: ~14.9% против ~17.1% в будни.
Интерпретация простая: в выходные люди чаще ездят более «спокойно» (ниже скорость), и при этом чуть реже встречаются совсем короткие «микропоездки», которые характерны для будней (добежать до метро/офиса и т. п.).
сгенерировано с использованием krea.ai// промт: white silhouette of a mountain bike with detailed gears and handlebars on a solid blue background, side view, high contrast, minimalist style
Вывод
В ходе проекта я загрузил и обработал большой массив данных о поездках городских велосипедов (миллионы записей), очистила аномалии и построила несколько типов визуализаций в едином стиле.
Главные наблюдения:
//по тепловой карте видно выраженные пики активности в дневные/вечерние часы и различия между днями недели;
//временной ряд показывает сезонность: рост к лету, пик в середине сезона и спад ближе к осени;
//распределение скоростей выглядит реалистично для городской езды, медиана около 12 км/ч;
//топ-маршруты показывают конкретные «коридоры» движения между станциями;
//сравнение будней и выходных даёт небольшой, но заметный эффект: в выходные скорость ниже примерно на 0.39 км/ч по медиане, а доля поездок < 1 км тоже ниже.
В итоге данные подтверждают, что City Bikes — это не случайные поездки, а устойчивая транспортная система с выраженным городским ритмом и понятными паттернами использования.
//обложка проекта сгенерирована с использованием krea.ai
//промт: A minimalistic, high-contrast silhouette of a man riding a vintage bicycle on a flat, textured surface. The entire scene is rendered in cyanotype style with a blue background and white outlines. The man wears a cap and vintage clothing, positioned on the right side of the frame with plenty of negative space on the left. Soft, subtle texture on the ground and sky for a dreamy, calm atmosphere.