python

Визуализация и анализ зимних температур Алматы за последние сто лет на Streamlit

  • вторник, 31 мая 2022 г. в 00:40:23
https://habr.com/ru/post/668672/
  • Python
  • Открытые данные
  • Визуализация данных
  • Экология


Введение

Недавно открыл для себя платформу Streamlit и был впечатлен простотой интеграции в питоновский проект. По детски, очень радовался тому что контроллеры на дашборде напрямую меняют питоновские переменные. И вот для тестирования решил поиграть с одной из тем которая мне очень интересна – климат. Начал с самого простого параметра который можно проанализировать – температуру воздуха с метеостанции города в котором я живу - Алматы (Казахстан). Интересно было посмотреть эффект глобального изменения климата на отдельно взятый город.

Для анализа скачал данные за 100 лет (1922-2022). Для начала использовал только месяцы с ноября по апрель включительно, во-первых, потому что в несвободное от работы время я работаю с морским льдом и зимний климат мне понятней, а во-вторых потому что в данных была пропущена неделя наблюдений в августе, в одном из годов.

Один из плюсов Streamlit, то что дашборды можно бесплатно публиковать в Streamlit Cloud, что я и сделал:

https://share.streamlit.io/yevkad/alaclim/main/app.py

Пройдя по этой ссылке, можно поиграть с интерактивными графиками. А в этом посте я поподробней покажу что и как я делал (с кодом) и опишу некоторые свои наблюдения.

Инструменты

Все было сделано на Python и следующими библиотеками:

  • Streamlit - платформа для быстрого создания DS/ML веб-приложений

  • Pandas - манипуляции с таблицами

  • Numpy - использовал некоторые вспомогательные функции

  • Plotly - Все интерактивные графики были построены с помощью этой библиотеки

  • SciPy - для подбора распределения вероятностей

  • pyMannKendall - для Теста Манна-Кендалла, который оценивает статистическую значимость тренда

import streamlit as st
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.figure_factory as ff
import plotly.graph_objects as go
import scipy
import pymannkendall as mk

from datetime import datetime

Данные

Основная часть данных была взята с NOAA Climate Data Center, к слову - один из любимых источников открытых данных, наравне с европейским Copernicus.

Почему-то было много пропусков после 2006го года, заполнил их данными rp5 - там трехчасовые данные с метеостанций доступны для большинства локаций с 2005го года. На rp5 данные в местном времени, предварительно перевел время в UTC и усреднил подневно.

Данные подготавливаются функцией представленной ниже. Декоратор st.cahce для того чтобы данные не обрабатывались каждый раз при обновлении контроллеров.

@st.cache
def get_winter_data(fl):
		#initial columns: date, at - daily mean air temperature
    df_winter=pd.read_csv(fl,parse_dates=['date'])
    df_winter['fd']=np.abs(np.minimum(0,df_winter['at'])) #freezing days
    df_winter['month']=df_winter['date'].dt.strftime('%b')
    
    #relative date for timeseries.
    df_winter['date_rel']=pd.to_datetime(df_winter['date'].dt.strftime('2016-%m-%d'),
                                         format='%Y-%m-%d')
    df_winter.loc[df_winter['mon']>8,
                  'date_rel']=df_winter['date_rel'] - pd.DateOffset(years=1)

    df_winter.loc[df_winter['date']<datetime(1972,6,1), 'period']='1922-1972'
    df_winter.loc[df_winter['date']>datetime(1972,6,1), 'period']='1972-2022'
    df_winter['dt']=df_winter['date'].dt.strftime('%Y%m').astype(int)
    
    df_fdd=df_winter.groupby(['seas'])['fd'].sum().reset_index(name='fdd')
    
    return df_winter,df_fdd

В результате получаем два датафрейма df_winter и df_fdd. У df_winter следующие колонки:

  • date - дата

  • at - среднедневная температура воздуха

  • seas - сезон, строка в виде начального и конечного года (1922-1923)

  • month - месяц в формате строчном формате (Nov, Dec, etc). От нее можно было бы избавиться и оптимизировать обработку, но руки пока не дошли

  • mon - месяц в числовом формате (11, 12 и тд.)

  • period - фиксированный период по 50 лет с 1922 по 1972 и 1972 по 2022

  • fd - freezing days, градусы ниже нуля в абсолюте

  • dt - полукостыль, год-месяц в виде целого числа в формате YYYYMM, для группирования нескольких сезонов в одну группу функцией np.digitize (пример будет в следующих секциях)

  • rel_date - дата с заменой года для всех дней на 2015 до января и 2016 с января, для того что бы можно было агрегировать 100 лет за каждый день.

У df_fdd всего 100 рядов, по ряду за каждый сезон и следующие колонки:

  • seas - сезон

  • fdd - Freezing Degree Days - аккумулированные градусы ниже нуля в абсолюте. Более подробное описание будет ниже.

Временной ряд

Временной ряд был составлен выведением на график агрегированных за каждый день с ноября по апрель столетних температур (максимум, минимум, среднее) и возможностью сравнивать эти параметры с выбранными периодами.

Если визуально сравнить первую половину периода со второй, то видно, что в основном средние температуры с 1922 по 1972 ниже столетнего среднего значения, а с 1972 по 2022 выше.

Сравнение средних температур за первые и последние 50 лет со столетними средними.
Сравнение средних температур за первые и последние 50 лет со столетними средними.

Распределения

Далее были построены гистограммы с подбором распределения вероятностей для двух периодов по 50 лет: 1922-1972 и 1972-2022. В падающем меню можно выбрать тип распределения - Нормальное или Гамма. В данном случае визуально понятно, что Нормальное больше подходит, но в будущем добавлю метрику "goodness of fit".

Левая часть распределения 1972-2022 "сужается" в право, по сравнению с периодом 1922-1972. Это показывает, что за последние 50 лет количество наблюдений экстремально холодных температур уменьшилось. Это не противоречит выведенными параметрами распределения - среднее (Mean) увеличилось на 0.6 градуса, а среднеквадратическое отклонение (SD) уменьшилось по сравнению с начальным периодом.

Распределение температур за два периода: 1922-1972 и 1972-2022
Распределение температур за два периода: 1922-1972 и 1972-2022

Усатые Ящики

Помимо сравнения двух фиксированных периодов в виде гистограмм и распределений описанных в предыдущей секции, также была добавлена возможность сравнения распределения выбранных групп периодов (1 год, 5 лет, 10 лет и тд). Наиболее подходящей визуализацией для этого, по моему мнению, являются графики, которые я называл всегда боксплотами, и только сегодня выяснил что русское называние для них - "ящик с усами".

Для тех кто не сталкивался с такими графиками, подробное описание "ящика с усами" можно почитать, например, здесь, но грубо и приближенно, вот как можно его интерпретировать:

  • "Ящик" содержит 50% наблюдений

  • Линия внутри ящика - медиана, или 2-й квартиль - выше и ниже по 50% наблюдений

  • Нижний и верхний "ус" - 25% нижних и верхних наблюдений соответственно

  • Точки - выбросы (наблюдения вне 2.7 среднеквадратических отклонений)

  • Нижняя граница "ящика" - 1-й квартиль - ниже него 25% наблюдений, выше 75%

  • Верхняя граница "ящика" - 3-й квартиль - выше него 25% наблюдений, ниже 75%

Сгруппировав по длительным периодам, например 20 лет, как на примере ниже, видно что, медиана и 1-й квартиль стабильно возрастает с периода который включает в себя 1970е, а 3-й квартиль в последнем периоде. Также как на распределениях описанных выше, изменение зим по большей части выражается в уменьшении "холодов", а в более поздние периоды еще и в увеличении "тепла".

Бокс плот температур воздуха, сгрупированные по 20 лет.
Бокс плот температур воздуха, сгрупированные по 20 лет.

Вот часть кода, где группируются температуры по годам и выводятся в график:

# Years grouping:
nmax=df_fdd.shape[0]
yr_start=df_winter['year'].min()

# list of only divisible groups
group_lst=[i for i in range(1,nmax+1,1) if nmax % i==0]

step=st.selectbox('Group years ', group_lst, index=group_lst.index(10))
date_range=np.array([datetime(yr_start+i,6,1).strftime('%Y%m')
for i in range(step,nmax+1,step)
]).astype(int)
date_bin_n=np.array([f'{yr_start+i}-{yr_start+step+i}' for i in range(0,nmax,step)])
df_winter_g=df_winter.copy()
#assigning bin name to date range
df_winter_g['date_bin']=date_bin_n[np.digitize(df_winter_g['dt'], date_range)]
#month filtering
mon_lst=df_winter['month'].unique()
st_mon_lst = st.multiselect("Months Used", mon_lst, default=mon_lst)
df_mon=df_winter_g[df_winter_g['month'].isin(st_mon_lst)]

fig_box = px.box(df_mon, x="date_bin", y="at",
labels={'seas':'Winter Season',
'at':'Air Temperature (\u00B0C)',
'date_bin':'Date Range'},)
st.write(fig_box)

Суровость зим по FDD

Сумма градусодней мороза (Freezing Degree Days - FDD) рассчитывается как абсолютная сумма всех отрицательных температур воздуха за период – обычно за зимний сезон. На всякий случай формула FDD:

FDD=\sum_i^N|min(0,at_i)|

Где at - среднедневная температура воздуха, N - число дней в сезоне.

FDD часто используется для примерного определения прироста толщины льда, уровня промерзания почвы, и может служить индексом суровости зим: чем меньше FDD тем мягче зима.

Отобразив на графике FDD за каждый сезон и положив линию тренда, то можно увидеть тенденцию снижения суровости зим, особенно с 1970х годов.

Тренд был посчитан в NumPy как полиномиальная кривая (с возможностью управлять порядком). 

poly_deg=st.slider('Degree of a Polynomial Trend',
                    min_value=1, max_value=20, value=1, step=1)

trend_x = np.arange(df_fdd.shape[0])
trend_fit = np.polyfit(trend_x, df_fdd['fdd'], poly_deg)
trend_fit_fn = np.poly1d(trend_fit)

Помимо визуальной оценки, тренд и его статистическая значимость была оценена тестом Манна-Кенадалла при помощи библиотеки pyMannKendall вот так:

mk_res=mk.original_test(df_fdd['fdd']) # results of Mann-Kendall Test

И вывести результат..

trend_mk=mk_res.trend # Trend: Increasing, Decreasing or No Trend
pval_mk=mk_res.p      # P-Value of MK Test

if pval_mk<0.05: # alpha=0.05
    p_str='**statistically significant**'
    alpha_compr='smaller'
else:
    p_str='**not statistically significant**'
    alpha_compr='greater'

#Markdown string generated to present MK Test results of FDD Trend
mk_str=f'''Mann-Kendall Test shows that FDD trend is **{trend_mk}**.
\n\n**P-value** is **{pval_mk:.3e}**, which is **{alpha_compr}**
than **0.05**, meaning that the trend in the data is {p_str}'''

И в таком виде результаты теста выводятся на страницу:

Результаты теста Манна-Кендалла
Результаты теста Манна-Кендалла

Ну и напоследок можно посмотреть указанное число наиболее мягких и наиболее суровых зим ранжированных по FDD. В пятерку наиболее мягких попали сезоны последних 30 лет. В пятерку наиболее суровых попали зимы не позже 1970х.

Заключение

Анализ столетних наблюдений температуры воздуха, показал что зимы в Алматы становятся мягче.

Весь дашборд уложился в менее чем 300 строк питоновского файла, значительную часть из которых занимает пояснительный текст. На составление основной части веб-приложения ушло несколько часов, ну и потом периодически что-то добавлял. Для сравнения, до знакомства со Streamlit я делал визуализацию с Bokeh+Flask, и на составление одного графика, когда я только начал изучать Bokeh, ушло полдня.

В будущем буду расширять дашборд новыми элементами и локациями. Добавлю сравнение средних максимумов и минимумов за период. Еще планирую добавить другие параметры, такие как ветер и осадки. Ну и если руки дойдут, может и моделирование какое-нибудь.