Как-то давно мы строили тепловую карту твитов Байдена и Трампа, чтобы увидеть визуальные паттерны в активностях двух президентов. Тогда мы использовали датасет на 18 000 записей. Сегодня пойдём дальше и обработаем уже 4 миллиона строк — а заодно посмотрим, как можно наглядно представить такой объём данных.
Суть проекта
Каждый день в городах разные организации подают разрешения на строительство, ремонт или снос разных объектов: зданий, киосков, стоянок и всего остального. Если проанализировать эти разрешения и посмотреть, есть ли у них что-то общее, то можно сделать какие-то выводы на основе этих данных.
Например, можно наложить на карту города количество разрешений, выданных в каждой точке, и посмотреть плотность застройки и динамику: как она менялась со временем. В результате мы получим что-то вроде этого:
Что понадобится
При работе с биг-датой есть правило: чем больше данных — тем лучше. Мы возьмём данные о разрешениях на строительные работы Нью-Йорка — они выложены в общий доступ, а ведут учёт заявок с 1989 года. На сентябрь 2022 года в базе почти 4 миллиона записей, а сам файл весит 1,5 гигабайта.
Этот файл нужно будет положить в папку с программой. Ещё нам понадобятся данные о границах Нью-Йорка — с ними мы сможем сами построить карту и привязаться к координатам строительных объектов. Эти данные после скачивания хранятся в нескольких файлах внутри папки Borough Boundaries. Папку с файлами нужно положить туда же, где и датасет.
Устанавливаем библиотеки
Так как мы будем писать код на Python, сначала нужно убедиться, что у нас установлена среда для исполнения Python-кода, а также сопровождающие её утилиты. Подробная инструкция: Как установить Python и начать на нём писать.
Нам понадобятся библиотеки для обработки данных и работы с изображениями. Установим всё заранее, чтобы потом не отвлекаться на это.
Начнём с простых библиотек. Откроем терминал и по очереди выполним такие команды:
pip install "dask[dataframe]"
pip install numpy
pip install colorcet
pip install matplotlib
pip install datashader
pip install imageio
С последней библиотекой, geopandas, могут возникнуть проблемы: у неё много сложных зависимостей, которые не всегда получается решить с первого раза. Поэтому, если команда pip install geopandas
выдала ошибку при установке, смотрите, что нужно сделать.
Windows: выполняем в терминале такие команды (pipwin — это частный репозиторий с готовыми библиотеками для Windows):
pip install wheel
pip install pipwin
pipwin install numpy
pipwin install pandas
pipwin install shapely
pipwin install gdal
pipwin install fiona
pipwin install pyproj
pipwin install six
pipwin install rtree
pipwin install geopandas
Mac OS: делаем так в командной строке:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install geos
export DYLD_LIBRARY_PATH=/opt/homebrew/opt/geos/lib/
brew install gdal
pip install fiona
brew install proj
pip install pyproj (или pip install pyproj==1.9.6, если будет ошибка)
pip install pygeos
pip install geopandas
Теперь можно начинать писать код.
Подключаем библиотеки и готовим датасет
Раз у нас все библиотеки установлены в систему, сразу подключим их, чтобы потом сразу с ними работать. Для этого создадим Python-файл bigdata.py
, положим его туда же, где лежит наш датасет, а потом запишем в этот файл такие команды (всё остальное тоже будем писать в тот же файл):
# подключаем библиотеку параллельных вычислений
import dask.dataframe as dd
# и библиотеку для обработки больших массивов с числами
import numpy as np
# библиотеки, которые нам пригодятся для построения изображений
from colorcet import fire
from matplotlib import pyplot as plt
import datashader as ds
import datashader.transfer_functions as tf
import imageio
# добавляем возможность работать с геокоординатами
from datashader.utils import lnglat_to_meters
# подключаем библиотеку для работы с геоданными
import geopandas as gpd
Теперь загрузим в память наш датасет. Так как нам нужны только координаты места и дата выдачи разрешения, то будем загружать только эти данные, чтобы сэкономить память и ускорить работу программы. Сразу выведем первые 5 строк, чтобы убедиться, что всё в порядке и мы всё загрузили верно. Для этого добавим в файл такое:
# считываем датасет
permits = dd.read_csv('DOB_Permit_Issuance.csv',
usecols=['Filing Date', 'LATITUDE', 'LONGITUDE'],
sep=';')
# выводим первые 5 записей, чтобы убедиться, что всё в порядке
print(permits.head())
Поменяем формат даты в привычный для Python вид, чтобы потом было удобнее брать оттуда год:
# меняем формат данных в колонке с датой
permits['Filing Date'] = dd.to_datetime(permits['Filing Date'])
# проверяем, что формат поменялся
print(permits.head())
И сразу добавим колонку с годом — по нему мы будем потом фильтровать все данные:
# создаём новую колонку для года
permits=permits.assign(year= permits['Filing Date'].dt.strftime("%Y"))
# и смотрим, добавилась она или нет
print(permits.head())
Чистим датасет
Чтобы не обрабатывать лишние данные, а сразу работать с правильными, мы почистим наш массив: переведём широту и долготу в координаты по осям X и Y, а также удалим все пустые записи. Такая пересборка займёт какое-то время, например, на макбуке с чипом M1 на это нужно секунд 20–30 (а в Jupyter-ноутбуке — от 5 до 10 минут):
# переводим широту и долготу в позицию по осям X и Y, это пригодится при работе с картами Google
permits['x'], permits['y'] = lnglat_to_meters(permits['LONGITUDE'], permits['LATITUDE'])
permits = permits.drop(['LONGITUDE', 'LATITUDE'], axis=1)
# удаляем все пустые строки и столбцы из датасета
permits = permits.dropna()
# добавляем в датасет индекс — используем для этого год
permits_indexed = permits.set_index('year')
Индексируем данные по годам
Для ускорения работы с датасетом мы проиндексируем каждую запись по году — это пригодится нам при сборке картинок. Смысл в том, чтобы пересортировать массив по нужному признаку и добавить индекс, чтобы компьютер мог быстро найти все записи с нужным годом.
Добавим вывод промежуточного индекса, чтобы проверить, какие годы нашёл наш алгоритм:
# создаём массив: берём все года, переводим их в строку и добавляем в массив
dateRange = list(range(1989,2023))
# на старте массив с годами — пустой
years = []
# перебираем все года из диапазона
for year in dateRange:
# переводим их в строку
str_year = str(year)
# и добавляем к нашему массиву
years.append(str_year)
# смотрим, что получилось
print(years)
# переразбиваем данные по годам (это тоже займёт какое-то время)
permits_repartitioned = permits_indexed.repartition(divisions=years)
# на основе переразбитого датасета создаём новый — это существенно ускорит создание картинок в дальнейшем
permits_computed = permits_repartitioned.compute()
Формируем карту с границей Нью-Йорка
Наша задача — взять геокоординаты города, перевести их координаты по осям и сформировать массив с координатами границ. Он нам пригодится, когда будем строить карту с отметками разрешений — на основе этого массива мы нарисуем карту с границами.
Единственное, что нам нужно сделать вручную, — указать с помощью геокоординат квадрат, в котором находится Нью-Йорк. Это поможет нам привязать широту и долготу к координатам на нашей карте:
# границы Нью-Йорка, ширина и долгота (с севера на юг и с востока на запад)
NYC = (( -74.25, -73.7), (40.50, 40.92))
# переводим в обычные координаты в метрах
x_range, y_range = [list(r) for r in lnglat_to_meters(NYC[0], NYC[1])]
# считываем файл с границами Нью-Йорка
NYCBoroughs= gpd.read_file("borough boundaries")
# указываем координаты в формате 3857)
NYCBoroughs = NYCBoroughs.to_crs({'init': 'epsg:3857'})
# ширина и высота будущей карты
plot_width = int(750)
plot_height = int(plot_width//1.2)
Вспомогательные функции
Нам понадобятся две дополнительные функции, которые сделают всю черновую работу — подготовят предварительное изображение и сформируют кадр для будущей гифки.
Задача первой — создать виртуальный холст, добавить туда информацию о том, как красить разные пиксели (чем плотнее застройка — тем ярче), и установить чёрный фон:
# функция, которая готовит картинку на основе входных данных
def create_image(df, x_range, y_range, w=plot_width, h=plot_height, cmap=fire):
# создаём холст
cvs = ds.Canvas(plot_width=w, plot_height=h, x_range=x_range, y_range=y_range)
# координаты точек
agg = cvs.points(df, 'x', 'y')
# красим пиксели в зависимости от их значения
img = tf.shade(agg, cmap=cmap, how='eq_hist')
# возвращаем картинку на чёрном фоне
return tf.set_background(img, "black").to_pil()
Вторая функция будет строить карту выдачи разрешений в конкретный год. Для этого ей понадобится первая функция, чтобы сделать макет, а потом на этот макет она нанесёт координаты точек, границы города и пояснительные надписи:
# функция, которая строит карту выдачи разрешений в выбранный год
def plot_permits_by_year(fig, all_data, year, city_limits, x_range, y_range):
# фиксируем год
df_this_year = all_data.loc[year]
# создаём новую картинку
img = create_image(df_this_year, x_range, y_range)
# делаем макет с помощью matplotlib
# очищаем картинку
plt.clf()
# размечаем оси
ax = fig.gca()
ax.imshow(img, extent=[x_range[0], x_range[1], y_range[0], y_range[1]])
# скрываем оси координат
ax.set_axis_off()
# строим границы Нью-Йорка
city_limits.plot(ax=ax, facecolor="none", edgecolor="white")
# добавляем надпись слева
ax.text(
0.0,
0.9,
"Карта выдачи разрешений\n\nЧем ярче —\nтем больше выдано",
color="white",
fontsize=20,
ha="left",
transform=ax.transAxes,
)
# и справа (подписываем год)
ax.text(
0.7,
0.1,
year,
color="white",
fontsize=40,
ha="left",
transform=ax.transAxes,
)
# подготавливаем виртуальный холст
fig.canvas.draw()
# масштабируем картинку под размер холста
image = np.frombuffer(fig.canvas.tostring_rgb(), dtype="uint8")
dims = (fig.canvas.get_width_height()[0] * 2, fig.canvas.get_width_height()[1] * 2)
image = image.reshape(dims[::-1] + (3,))
# возвращаем результат
return image
Собираем всё в одну картинку
У нас всё готово для сборки: есть все данные и функции, которые могут построить картинку для конкретного года. Теперь сделаем так:
- Сделаем пустую финальную картинку.
- Переберём весь массив с годами и сформируем картинку конкретно для этого года.
- Добавим этот кадр к финальной картинке.
- Прогоним это цикл до тех пор, пока не закончатся года в списке.
В итоге на выходе мы получим анимированную картинку, где каждый кадр — это определённый год:
# у нас будет несколько графиков на одной картинке
fig, ax = plt.subplots(figsize=(10,10), facecolor='black')
# генерируем картинки для каждого года
imgs = []
# перебираем массив с годами
for year in years:
# выводим данные о текущем годе, с которым работаем
print('Обрабатываем ' + year + ' ...')
# формируем новый кадр с данными этого года
img = plot_permits_by_year(fig, permits_computed, year, NYCBoroughs, x_range=x_range, y_range=y_range)
# добавляем его к общей картинке
imgs.append(img)
# собираем всё в одну гифку
imageio.mimsave('1989_2022_permits.gif', imgs, fps=1)
# подключаем библиотеку параллельных вычислений
import dask.dataframe as dd
# и библиотеку для обработки больших массивов с числами
import numpy as np
# библиотеки, которые нам пригодятся для построения изображений
from colorcet import fire
from matplotlib import pyplot as plt
import datashader as ds
import datashader.transfer_functions as tf
import imageio
# добавляем возможность работать с геокоординатами
from datashader.utils import lnglat_to_meters
# подключаем библиотеку для работы с геоданными
import geopandas as gpd
# считываем датасет
permits = dd.read_csv('DOB_Permit_Issuance.csv',
usecols=['Filing Date', 'LATITUDE', 'LONGITUDE'],
sep=';')
# выводим первые 5 записей, чтобы убедиться, что всё в порядке
print(permits.head())
# меняем формат данных в колонке с датой
permits['Filing Date'] = dd.to_datetime(permits['Filing Date'])
# проверяем, что формат поменялся
print(permits.head())
# создаём новую колонку для года
permits=permits.assign(year= permits['Filing Date'].dt.strftime("%Y"))
# и смотрим, добавилась она или нет
print(permits.head())
# переводим широту и долготу в позицию по осям X и Y, это пригодится при работе с картами Google
permits['x'], permits['y'] = lnglat_to_meters(permits['LONGITUDE'], permits['LATITUDE'])
permits = permits.drop(['LONGITUDE', 'LATITUDE'], axis=1)
# удаляем все пустые строки и столбцы из датасета
permits = permits.dropna()
# добавляем в датасет индекс — используем для этого год (на это потребуется какое-то время)
permits_indexed = permits.set_index('year')
# создаём массив: берём все года, переводим их в строку и добавляем в массив
dateRange = list(range(1989,2023))
# на старте массив с годами — пустой
years = []
# перебираем все года из диапазона
for year in dateRange:
# переводим их в строку
str_year = str(year)
# и добавляем к нашему массиву
years.append(str_year)
# смотрим, что получилось
print(years)
# переразбиваем данные по годам (это тоже займёт какое-то время)
permits_repartitioned = permits_indexed.repartition(divisions=years)
# на основе переразбитого датасета создаём новый — это существенно ускорит создание картинок в дальшейшем
permits_computed = permits_repartitioned.compute()
# границы Нью-Йорка, ширина и долгота (с севера на юг и с востока на запад)
NYC = (( -74.25, -73.7), (40.50, 40.92))
# переводим в обычные координаты в метрах
x_range, y_range = [list(r) for r in lnglat_to_meters(NYC[0], NYC[1])]
# считываем файл с границами Нью-Йорка
NYCBoroughs= gpd.read_file("borough boundaries")
# указываем координаты в формате 3857)
NYCBoroughs = NYCBoroughs.to_crs({'init': 'epsg:3857'})
# ширина и высота будущей карты
plot_width = int(750)
plot_height = int(plot_width//1.2)
# функция, которая готовит картинку на основе входных данных
def create_image(df, x_range, y_range, w=plot_width, h=plot_height, cmap=fire):
# создаём холст
cvs = ds.Canvas(plot_width=w, plot_height=h, x_range=x_range, y_range=y_range)
# координаты точек
agg = cvs.points(df, 'x', 'y')
# красим пиксели в зависимости от их значения
img = tf.shade(agg, cmap=cmap, how='eq_hist')
# возвращаем картинку на чёрном фоне
return tf.set_background(img, "black").to_pil()
# функция, которая строит карту выдачи разрешений в выбранный год
def plot_permits_by_year(fig, all_data, year, city_limits, x_range, y_range):
# фиксируем год
df_this_year = all_data.loc[year]
# создаём новую картинку
img = create_image(df_this_year, x_range, y_range)
# делаем макет с помощью matplotlib
# очищаем картинку
plt.clf()
# размечаем оси
ax = fig.gca()
ax.imshow(img, extent=[x_range[0], x_range[1], y_range[0], y_range[1]])
# скрываем оси координат
ax.set_axis_off()
# строим границы Нью-Йорка
city_limits.plot(ax=ax, facecolor="none", edgecolor="white")
# добавляем надпись слева
ax.text(
0.0,
0.9,
"Карта выдачи разрешений\n\nЧем ярче —\nтем больше выдано",
color="white",
fontsize=20,
ha="left",
transform=ax.transAxes,
)
# и справа (подписываем год)
ax.text(
0.7,
0.1,
year,
color="white",
fontsize=40,
ha="left",
transform=ax.transAxes,
)
# подготавливаем виртуальный холст
fig.canvas.draw()
# масштабируем картинку под размер холста
image = np.frombuffer(fig.canvas.tostring_rgb(), dtype="uint8")
dims = (fig.canvas.get_width_height()[0] * 2, fig.canvas.get_width_height()[1] * 2)
image = image.reshape(dims[::-1] + (3,))
# возвращаем результат
return image
# у нас будет несколько графиков на одной картинке
fig, ax = plt.subplots(figsize=(10,10), facecolor='black')
# генерируем картинки для каждого года
imgs = []
# перебираем массив с годами
for year in years:
# выводим данные о текущем годе, с которым работаем
print('Обрабатываем ' + year + ' ...')
# формируем новый кадр с данными этого года
img = plot_permits_by_year(fig, permits_computed, year, NYCBoroughs, x_range=x_range, y_range=y_range)
# добавляем его к общей картинке
imgs.append(img)
# собираем всё в одну гифку
imageio.mimsave('1989_2022_permits.gif', imgs, fps=1)