Блог об аналитике, визуализации данных, data science и BI

    Valiotti Analytics — построение аналитики для мобильных и digital-стартапов

Python и тексты нового альбома Земфиры: анализируем суть песен

Время чтения текста – 18 минут

Неделю назад вышёл первый за 8 лет студийный альбом Земфиры «Бордерлайн». К работе помимо рок-певицы приложили руку разные люди, в том числе и её родственники — рифф для песни «таблетки» написал её племянник из Лондона. Альбом получился разнообразным: например, песня «остин» посвящена главному персонажу игры Homescapes российской студии Playrix (кстати, посмотрите свежие Бизнес-секреты с братьями Бухманами, там они тоже про это рассказывают) — Земфире нравится игра, и для трека она связалась со студией. А сингл «крым» был написан в качестве саундтрека к новой картине соратницы Земфиры — Ренаты Литвиновой.

Послушать альбом в Apple Music / Яндекс.Музыке / Spotify

Тем не менее, дух всего альбома довольно мрачен — в песнях часто повторяются слова «боль», «ад», «бесишь» и прочие по смыслу. Мы решили провести разведочный анализ нового альбома, а затем при помощи модели Word2Vec и косинусной меры посмотреть на семантическую близость песен между собой и узнать вычислить общее настроение альбома.

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

Подготовка данных

Для начала работы напишем скрипт обработки данных. Цель скрипта — из множества текстовых файлов, в каждом из которых лежит по песне, собрать единую csv-таблицу. При этом текст треков очищаем от знаков пунктуации и ненужных слов.

import pandas as pd
import re
import string
import pymorphy2
from nltk.corpus import stopwords

Создаём морфологический анализатор и расширяем список всего, что нужно отбросить:

morph = pymorphy2.MorphAnalyzer()
stopwords_list = stopwords.words('russian')
stopwords_list.extend(['куплет', 'это', 'я', 'мы', 'ты', 'припев', 'аутро', 'предприпев', 'lyrics', '1', '2', '3', 'то'])
string.punctuation += '—'

Названия песен приведены на английском — создадим словарь для перевода на русский и словарь, из которого позднее сделаем таблицу:

result_dict = dict()

songs_dict = {
    'snow':'снег идёт',
    'crimea':'крым',
    'mother':'мама',
    'ostin':'остин',
    'abuse':'абьюз',
    'wait_for_me':'жди меня',
    'tom':'том',
    'come_on':'камон',
    'coat':'пальто',
    'this_summer':'этим летом',
    'ok':'ок',
    'pills':'таблетки'
}

Опишем несколько функций. Первая читает целиком песню из файла и удаляет переносы строки, вторая очищает текст от ненужных символов и слов, а третья при помощи морфологического анализатора pymorphy2 приводит слова к нормальной форме. Модуль pymorphy2 не всегда хорошо справляется с неоднозначностью — для слов «ад» и «рай» потребуется дополнительная обработка.

def read_song(filename):
    f = open(f'{filename}.txt', 'r').read()
    f = f.replace('\n', ' ')
    return f

def clean_string(text):
    text = re.split(' |:|\.|\(|\)|,|"|;|/|\n|\t|-|\?|\[|\]|!', text)
    text = ' '.join([word for word in text if word not in string.punctuation])
    text = text.lower()
    text = ' '.join([word for word in text.split() if word not in stopwords_list])
    return text

def string_to_normal_form(string):
    string_lst = string.split()
    for i in range(len(string_lst)):
        string_lst[i] = morph.parse(string_lst[i])[0].normal_form
        if (string_lst[i] == 'аду'):
            string_lst[i] = 'ад'
        if (string_lst[i] == 'рая'):
            string_lst[i] = 'рай'
    string = ' '.join(string_lst)
    return string

Проходим по каждой песне и читаем файл с соответствующим названием:

name_list = []
text_list = []
for song, name in songs_dict.items():
    text = string_to_normal_form(clean_string(read_song(song)))
    name_list.append(name)
    text_list.append(text)

Затем объединяем всё в DataFrame и сохраняем в виде csv-файла.

df = pd.DataFrame()
df['name'] = name_list
df['text'] = text_list
df['time'] = [290, 220, 187, 270, 330, 196, 207, 188, 269, 189, 245, 244]
df.to_csv('borderline.csv', index=False)

Результат:

Облако слов по всему альбому

Начнём анализ с построения облака слов — оно отобразит, какие слова чаще всего встречаются в песнях. Импортируем нужные библиотеки, читаем csv-файл и устанавливаем конфигурации:

import nltk
from wordcloud import WordCloud
import pandas as pd
import matplotlib.pyplot as plt
from nltk import word_tokenize, ngrams

%matplotlib inline
nltk.download('punkt')
df = pd.read_csv('borderline.csv')

Теперь создаём новую фигуру, устанавливаем параметры оформления и при помощи библиотеки wordcloud отображаем слова с размером прямо пропорциональным частоте упоминания слова. Над каждым графиком дополнительно указываем название песни.

fig = plt.figure()
fig.patch.set_facecolor('white')
plt.subplots_adjust(wspace=0.3, hspace=0.2)
i = 1
for name, text in zip(df.name, df.text):
    tokens = word_tokenize(text)
    text_raw = " ".join(tokens)
    wordcloud = WordCloud(colormap='PuBu', background_color='white', contour_width=10).generate(text_raw)
    plt.subplot(4, 3, i, label=name,frame_on=True)
    plt.tick_params(labelsize=10)
    plt.imshow(wordcloud)
    plt.axis("off")
    plt.title(name,fontdict={'fontsize':7,'color':'grey'},y=0.93)
    plt.tick_params(labelsize=10)
    i += 1

EDA текстов альбома

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

import plotly.graph_objects as go
import plotly.figure_factory as ff
from scipy import spatial
import collections
import pymorphy2
import gensim

morph = pymorphy2.MorphAnalyzer()

Сначала посчитаем число слов в каждой песне, число уникальных слов и процентное соотношение:

songs = []
total = []
uniq = []
percent = []

for song, text in zip(df.name, df.text):
    songs.append(song)
    total.append(len(text.split()))
    uniq.append(len(set(text.split())))
    percent.append(round(len(set(text.split())) / len(text.split()), 2) * 100)

А теперь составим из этого DataFrame и дополнительно посчитаем число слов в минуту для каждой песни:

df_words = pd.DataFrame()
df_words['song'] = songs
df_words['total words'] = total
df_words['uniq words'] = uniq
df_words['percent'] = percent
df_words['time'] = df['time']
df_words['words per minute'] = round(total / (df['time'] // 60))
df_words = df_words[::-1]

Данные хорошо бы визуализировать — построим две столбиковые диаграммы: одну для числа слов в песне, а другую для числа слов в минуту.

colors_1 = ['rgba(101,181,205,255)'] * 12
colors_2 = ['rgba(62,142,231,255)'] * 12

fig = go.Figure(data=[
    go.Bar(name='📝 Всего слов',
           text=df_words['total words'],
           textposition='auto',
           x=df_words.song,
           y=df_words['total words'],
           marker_color=colors_1,
           marker=dict(line=dict(width=0)),),
    go.Bar(name='🌀 Уникальных слов',
           text=df_words['uniq words'].astype(str) + '<br>'+ df_words.percent.astype(int).astype(str) + '%' ,
           textposition='inside',
           x=df_words.song,
           y=df_words['uniq words'],
           textfont_color='white',
           marker_color=colors_2,
           marker=dict(line=dict(width=0)),),
])

fig.update_layout(barmode='group')

fig.update_layout(
    title = 
        {'text':'<b>Соотношение числа уникальных слов к общему количеству</b><br><span style="color:#666666"></span>'},
    showlegend = True,
    height=650,
    font={
        'family':'Open Sans, light',
        'color':'black',
        'size':14
    },
    plot_bgcolor='rgba(0,0,0,0)',
)
fig.update_layout(legend=dict(
    yanchor="top",
    xanchor="right",
))

fig.show()
colors_1 = ['rgba(101,181,205,255)'] * 12
colors_2 = ['rgba(238,85,59,255)'] * 12

fig = go.Figure(data=[
    go.Bar(name='⏱️ Длина трека, мин.',
           text=round(df_words['time'] / 60, 1),
           textposition='auto',
           x=df_words.song,
           y=-df_words['time'] // 60,
           marker_color=colors_1,
           marker=dict(line=dict(width=0)),
          ),
    go.Bar(name='🔄 Слов в минуту',
           text=df_words['words per minute'],
           textposition='auto',
           x=df_words.song,
           y=df_words['words per minute'],
           marker_color=colors_2,
           textfont_color='white',
           marker=dict(line=dict(width=0)),
          ),
])

fig.update_layout(barmode='overlay')

fig.update_layout(
    title = 
        {'text':'<b>Длина трека и число слов в минуту</b><br><span style="color:#666666"></span>'},
    showlegend = True,
    height=650,
    font={
        'family':'Open Sans, light',
        'color':'black',
        'size':14
    },
    plot_bgcolor='rgba(0,0,0,0)'
)


fig.show()

Работа с Word2Vec моделью

При помощи модуля gensim загружаем модель, указывая на бинарный файл:

model = gensim.models.KeyedVectors.load_word2vec_format('model.bin', binary=True)

Для материала мы использовали готовую обученную на Национальном Корпусе Русского Языка модель от сообщества RusVectōrēs

Модель Word2Vec основана на нейронных сетях и позволяет представлять слова в виде векторов, учитывая семантическую составляющую. Это означает, что если мы возьмём два слова — например, «мама» и «папа», представим их в виде двух векторов и посчитаем косинус, значения будет близко к 1. Аналогично, у двух слов, не имеющих ничего общего по смыслу косинусная мера близка к 0.

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

def get_vector(word_list):
    vector = 0
    for word in word_list:
        pos = morph.parse(word)[0].tag.POS
        if pos == 'INFN':
            pos = 'VERB'
        if pos in ['ADJF', 'PRCL', 'ADVB', 'NPRO']:
            pos = 'NOUN'
        if word and pos:
            try:
                word_pos = word + '_' + pos
                this_vector = model.word_vec(word_pos)
                vector += this_vector
            except KeyError:
                continue
    return vector

Для каждой песни находим вектор и собираем соответствующий столбец в DataFrame:

vec_list = []
for word in df['text']:
    vec_list.append(get_vector(word.split()))
df['vector'] = vec_list

Теперь сравним вектора между собой, посчитав их косинусную близость. Те песни, у которых косинусная метрика выше 0,5 запомним отдельно — так мы получим самые близкие пары песен. Данные о сравнении векторов запишем в двумерный список result.

similar = dict()
result = []
for song_1, vector_1 in zip(df.name, df.vector):
    sub_list = []
    for song_2, vector_2 in zip(df.name.iloc[::-1], df.vector.iloc[::-1]):
        res = 1 - spatial.distance.cosine(vector_1, vector_2)
        if res > 0.5 and song_1 != song_2 and (song_1 + ' / ' + song_2 not in similar.keys() and song_2 + ' / ' + song_1 not in similar.keys()):
            similar[song_1 + ' / ' + song_2] = round(res, 2)
        sub_list.append(round(res, 2))
    result.append(sub_list)

Самые похожие треки соберём в отдельный DataFrame:

df_top_sim = pd.DataFrame()
df_top_sim['name'] = list(similar.keys())
df_top_sim['value'] = list(similar.values())
df_top_sim.sort_values(by='value', ascending=False)

И построим такой же bar chart:

colors = ['rgba(101,181,205,255)'] * 5

fig = go.Figure([go.Bar(x=df_top_sim['name'],
                        y=df_top_sim['value'],
                        marker_color=colors,
                        width=[0.4,0.4,0.4,0.4,0.4],
                        text=df_top_sim['value'],
                        textfont_color='white',
                        textposition='auto')])

fig.update_layout(
    title = 
        {'text':'<b>Топ-5 схожих песен</b><br><span style="color:#666666"></span>'},
    showlegend = False,
    height=650,
    font={
        'family':'Open Sans, light',
        'color':'black',
        'size':14
    },
    plot_bgcolor='rgba(0,0,0,0)',
    xaxis={'categoryorder':'total descending'}
)

fig.show()

Имея вектор каждой песни, давайте посчитаем вектор всего альбома — сложим вектора песен. Затем для такого вектора при помощи модели получим самые близкие по духу и смыслу слова.

def get_word_from_tlist(lst):
    for word in lst:
        word = word[0].split('_')[0]
        print(word, end=' ')

vec_sum = 0
for vec in df.vector:
    vec_sum += vec
sim_word = model.similar_by_vector(vec_sum)
get_word_from_tlist(sim_word)

небо тоска тьма пламень плакать горе печаль сердце солнце мрак

Наверное, это ключевой результат и описание альбома Земфиры всего лишь в 10 словах.

Наконец, построим общую тепловую карту, каждая ячейка которой — результат сравнения косинусной мерой текстов двух треков.

colorscale=[[0.0, "rgba(255,255,255,255)"],
            [0.1, "rgba(229,232,237,255)"],
            [0.2, "rgba(216,222,232,255)"],
            [0.3, "rgba(205,214,228,255)"],
            [0.4, "rgba(182,195,218,255)"],
            [0.5, "rgba(159,178,209,255)"],
            [0.6, "rgba(137,161,200,255)"],
            [0.7, "rgba(107,137,188,255)"],
            [0.8, "rgba(96,129,184,255)"],
            [1.0, "rgba(76,114,176,255)"]]

font_colors = ['black']
x = list(df.name.iloc[::-1])
y = list(df.name)
fig = ff.create_annotated_heatmap(result, x=x, y=y, colorscale=colorscale, font_colors=font_colors)
fig.show()

Результаты анализа и интерпретация данных

Давайте ещё раз посмотрим на всё, что у нас получилось — начнём с облака слов. Нетрудно заметить, что у слов «боль», «невозможно», «сорваться», «растерзаны», «сложно», «терпеть», «любить» размер весьма приличный — всё потому, что такие слова встречаются часто на протяжении всего текста песен:

Одной из самых «разнообразных» песен оказался сингл «крым» — в нём 74% уникальных слов. А в песне «снег идёт» слов совсем мало, поэтому большинство — 82% уникальны. Самой большой песней в альбоме получился трек «таблетки» — суммарно там около 150 слов.

Как было выяснено на прошлом графике, самый «динамичный» трек — «таблетки», целых 37 слов в минуту — практически по слову на каждые две секунды. А самый длинный трек — «абъюз», в нём же и согласно предыдущему графику практически самый низкий процент уникальных слов — 46%.

Топ-5 самых семантически похожих пар текстов:

Ещё мы получили вектор всего альбома и подобрали самые близкие слова. Только посмотрите на них — «тьма», «тоска», «плакать», «горе», «печаль», «сердце» — это же ведь и есть тот перечень слов, который характеризует лирику Земфиры!

небо тоска тьма пламень плакать горе печаль сердце солнце мрак

Финал — тепловая карта. По визуализации заметно, что практически все песни достаточно схожи между собой — косинусная мера у многих пар превышает значение в 0.4.

Выводы

В материале мы провели EDA всего текста нового альбома и при помощи предобученной модели Word2Vec доказали гипотезу — большинство песен «бордерлайна» пронизывают довольно мрачные и тексты. И это нормально, ведь Земфиру мы любим именно за искренность и прямолинейность.

Экспорт исторических данных Apple Health в Google Sheets

Время чтения текста – 9 минут

Для устройств на базе iOS и watchOS существует приложение Health, которое ежедневно записывает все данные о здоровье носителя и синхронизирует их со сторонними приложениями. Все эти данные в любой момент можно получить прямо из приложения в виде XML-документа. Сегодня мы выгрузим исторические данные о здоровье из приложения Apple Health, обработаем их и отправим в Google Sheets для анализа и визуализации в будущем.

Экспорт архива из приложения

Зайдите в приложение Health на iPhone. Нажмите на аватарку своего профиля в верхнем правом углу — откроется меню приложения.

Внизу нажмите на кнопку «Экспортировать медданные». Через некоторое время откроется меню экспорта — отправьте архив себе на компьютер любым способом, можно по AirDrop или даже по почте в письме самому себе. Из архива нужен только один файл — «экспорт.xml». Достаньте его и положите в папку с ноутбуком jupyter.

Парсер XML в DataFrame

При помощи библиотеки XML составляем дерево на основе документа из Health. Собирать в словарь будем следующие атрибуты: тип, единица измерения, дата создания, дата начала, дата конца, значение. Проходим по всему дереву и отправляем полученные значения атрибутов в records_dict.

from xml.etree import ElementTree
import pandas as pd
import datetime

tree = ElementTree.parse('экспорт.xml')
root = tree.getroot()
records = root.findall('Record')

records_dict = {
    'type':[],
    'unit':[],
    'creationDate':[],
    'startDate':[],
    'endDate':[],
    'value':[]
}

for record in records:
    for attribute in records_dict.keys():
        attribute_value = record.get(attribute)
        records_dict[attribute].append(attribute_value)

События записаны в нечитабельном виде — для перевода составим специальный словарь с нужными типами, где ключ — старое название, а значение — новое. Мы возьмём только 11 событий: минуты осознанности, дистанция на велосипеде, дистанция заплыва, дистанция ходьбы и бега, пройдено пролётов, пульс, пульс в покое, шаги, активная энергия, энергия покоя и средний пульс при ходьбе.

types_dict = {
    'HKCategoryTypeIdentifierMindfulSession': 'Mindful Session',
    'HKQuantityTypeIdentifierDistanceCycling': 'Cycling Distance',
    'HKQuantityTypeIdentifierDistanceSwimming': 'Swimming Distance',
    'HKQuantityTypeIdentifierDistanceWalkingRunning': 'Walking + Running Distance',
    'HKQuantityTypeIdentifierFlightsClimbed': 'Flights Climbed',
    'HKQuantityTypeIdentifierHeartRate': 'Heart Rate',
    'HKQuantityTypeIdentifierRestingHeartRate': 'Resting Heart Rate',
    'HKQuantityTypeIdentifierStepCount': 'Steps',
    'HKQuantityTypeIdentifierActiveEnergyBurned': 'Active Calories',
    'HKQuantityTypeIdentifierBasalEnergyBurned': 'Resting Calories',
    'HKQuantityTypeIdentifierWalkingHeartRateAverage': 'Walking Heart Rate Average'
}

Для минут осознанности в поле значения записей нет — мы сами посчитаем позже это поле как разницу даты окончания и начала события. Разница будет представлена как timedelta, поэтому напишем функцию перевода timedelta в минуты:

def td_to_m(td):
    seconds = td.seconds + td.days * 24 * 60 * 60
    return seconds // 60

Из словаря создаём DataFrame и задаём названия колонок. Оставляем только те 11 событий, которые есть в словаре types_dict и приводим все колонки к нужным типам данных:

df = pd.DataFrame(records_dict)
df.columns = ['type', 'unit', 'date', 'start', 'end', 'value']
df = df[df['type'].isin(types_dict.keys())]
df['value'] = df['value'].astype(float)
df['date'] = df['date'].astype('datetime64')
df['date'] = df['date'].dt.date
df['start'] = df['start'].astype('datetime64')
df['end'] = df['end'].astype('datetime64')
df['unit'] = df['unit'].astype(str)

Данные Health при экспорте никак не группируются — мы сделаем это самостоятельно. DataFrame можно поделить на три: в первом будут события, у которых единица измерения «количество в минуту» — для таких событий нужно искать среднее значение. В другой группе будут минуты осознанности — считаем число минут в каждой записи и суммируем. В последней группе находятся все остальные записи, связанные с количественными событиями — шаги, дистанция ходьбы и бега и так далее. Их тоже суммируем.

df_1 = df[df['unit'] == 'count/min']
df_1 = df_1.groupby(by=['date', 'type', 'unit'], as_index=False).agg({'start':'min',
                                                                      'end':'max',
                                                                      'value':'mean'})

df_2 = df[df['type'] == 'HKCategoryTypeIdentifierMindfulSession']
df_2['value'] = df_2['end'] - df_2['start']
df_2['value'] = df_2['value'].map(td_to_m)
df_2 = df_2.groupby(by=['date', 'type', 'unit'], as_index=False).agg({'start':'min',
                                                                     'end':'max',
                                                                     'value':'sum'})
df_3 = df[(df['unit'] != 'count/min') & (df['type'] != 'HKCategoryTypeIdentifierMindfulSession')]
df_3 = df_3.groupby(by=['date', 'type', 'unit'], as_index=False).agg({'start':'min',
                                                                      'end':'max',
                                                                      'value':'sum'})
df = pd.concat([df_1, df_2, df_3])

Дату создания записи переводим в строковый тип. Все наименования типов событий заменяем согласно словарю types_dict. В переменную dates записываем все уникальные даты.

df['date'] = df['date'].astype(str)
df['type'] = df['type'].apply(lambda x: types_dict[x])
dates = df['date'].unique()

В результате нужен словарь с колонкой даты и отдельной колонкой под каждое из 11 событий:

result = {
    'date': [],
    'Steps': [],
    'Walking + Running Distance': [],
    'Swimming Distance': [],
    'Cycling Distance': [],
    'Resting Calories': [],
    'Active Calories': [],
    'Flights Climbed': [],
    'Heart Rate': [],
    'Resting Heart Rate': [],
    'Walking Heart Rate Average': [],
    'Mindful Session': []
}

Проходим по каждой дате и получаем кусок DataFrame за эту дату. Добавляем её в словарь и проходим по каждому ключу, пробуя добавить значение:

for date in dates:
    part = df[df['date'] == date]
    result['date'].append(date)
    for key in result.keys():
        if key == 'date':
            continue
        else:
            field = 'value'
        try:
            result[key].append(part[part['type'] == key][field].values[0])
        except IndexError:
            result[key].append(None)

Из полученного словаря создаём DataFrame, округляем всё до двух знаков после запятой и сортируем по дате:

result_df = pd.DataFrame(result)
result_df = result_df.round(2)
result_df = result_df.sort_values(by='date')

В результате получается такая таблица с историческими данными по 11 событиям:

Экспорт DataFrame в Google Sheets

Для экспорта в Google Docs необходим сервисный аккаунт и json-файл с ключом. О том, как его получить, мы писали в материале «Собираем данные по рекламным кампаниям ВКонтакте»

Создайте новый документ в Google Sheets. Весь DataFrame можно вставить одним действием при помощи методов библиотеки gspread. Импортируйте её, а также укажите идентификатор документа и json-файл с ключом. В методе get_worksheet указывается порядковый номер листа в файле начиная с нуля.

import pandas as pd
import gspread
from gspread_dataframe import set_with_dataframe
gc = gspread.service_account(filename='serviceAccount.json')
sh = gc.open_by_key('1osKA63LQkUC0FC0eIZ63jEJwn1TeIkUvqCV6ur')
worksheet = sh.get_worksheet(0)

В итоге в Google Spreadsheets появится такая таблица:

А в следующем материале посмотрим, как наладить ежедневный экспорт данных Здоровья в эту таблицу при помощи шорткатов и Google AppScript!

Генерация Open Graph Image в Эгее

Время чтения текста – 12 минут

Протокол Open Graph нужен, чтобы управлять представлением контента сайта в социальных сетях. По сути — это набор тегов, позволяющий задать то, как будет отображаться информация о сайте на других площадках. В том числе можно указать картинку, которая будет отображаться в постах. Сегодня мы посмотрим, как для блога на Эгее доработать Open Graph разметку и заострим внимание на Open Graph Image — мы сделаем так, чтобы для каждого поста генерировалась своя картинка с наименованием материала.

Подготовка

Перед настройкой тегов протокола Open Graph сделаем скрипт, который генерирует изображения. В результате картинка представляет собой фон и два текста: время чтения и название материала, поэтому мы заранее подготовили такое фоновое изображение для ВКонтакте:

Подробнее об оформлении сниппетов для ВКонтакте можно почитать в документации

У социальных сетей разные требования по размеру изображений: ВКонтакте, например, рекомендует 510×228 пикселей. В корне сайта создадим папку og-image-files. Перейдём в неё, положим туда подготовленное фоновое изображение и создадим ещё одну папку внутри под названием og-fonts для шрифтов на изображении.

Написание скрипта для генерации изображения

Скрипт будем писать на php. Так как он генерирует картинку с пользовательским текстом, мы реализуем поддержку параметров, переданных в ссылке. Создаём в директории og-image-files файл og-image.php и открываем его. Весь код, написанный на php нужно заключить в тег <?php … >. Первым делом опишем функцию, которая откроет фоновое изображение. Пробуем открыть изображение и вернуть его. Если не удалось — создаём пустое белое и выводим сообщение об ошибке на нём.


Функция загрузки изображения

<?php
function LoadPNG($imgname)
{
   $im = @imagecreatefrompng($imgname);

   if(!$im)
   {
       $im  = imagecreatetruecolor(150, 30);
       $bgc = imagecolorallocate($im, 255, 255, 255);
       $tc  = imagecolorallocate($im, 0, 0, 0);

       imagefilledrectangle($im, 0, 0, 150, 30, $bgc);

       imagestring($im, 1, 5, 5, 'Ошибка загрузки ' . $imgname, $tc);
   }

   return $im;
}

Следом опишем другую функцию: она будет помещать текст на картинку, автоматически расставляя переносы строки. Она принимает картинку, размер текста, угол наклона, отступ слева и сверху, цвет текста, шрифт, сам текст и максимальную ширину. Внутри строка делится на массив слов, считаются все необходимые значения ширины текста, а затем в цикле for элементы массива помещаются на изображение.


Функция загрузки изображения

function imagettftextjustified(&$image, $size, $angle, $left, $top, $color, $font, $text, $max_width, $minspacing = 3, $linespacing = 1)
{
   $wordwidth = array();
   $linewidth = array();
   $linewordcount = array();
   $largest_line_height = 0;
   $lineno = 0;
   $words = explode(" ", $text);
   $wln = 0;
   $linewidth[$lineno] = 0;
   $linewordcount[$lineno] = 0;
   foreach ($words as $word)
   {
       $dimensions = imagettfbbox($size, $angle, $font, $word);
       $line_width = $dimensions[2] - $dimensions[0];
       $line_height = $dimensions[1] - $dimensions[7];
       if ($line_height > $largest_line_height) $largest_line_height = $line_height;
       if (($linewidth[$lineno] + $line_width + $minspacing) > $max_width)
       {
           $lineno++;
           $linewidth[$lineno] = 0;
           $linewordcount[$lineno] = 0;
           $wln = 0;
       }
       $linewidth[$lineno] += $line_width + $minspacing;
       $wordwidth[$lineno][$wln] = $line_width;
       $wordtext[$lineno][$wln] = $word;
       $linewordcount[$lineno]++;
       $wln++;
   }
   for ($ln = 0;$ln <= $lineno;$ln++)
   {
       $slack = $max_width - $linewidth[$ln];
       if (($linewordcount[$ln] > 1) && ($ln != $lineno)) $spacing = ($slack / ($linewordcount[$ln] - 1));
       else $spacing = $minspacing;
       $x = 0;
       for ($w = 0;$w < $linewordcount[$ln];$w++)
       {
           imagettftext($image, $size, $angle, $left + intval($x) , $top + $largest_line_height + ($largest_line_height * $ln * $linespacing) , $color, $font, $wordtext[$ln][$w]);
           $x += $wordwidth[$ln][$w] + 20; //+ $spacing + $minspacing;
       }
   }
   return true;
}

Теперь основная часть: так как при переходе на страницу со скриптом должна отображаться исключительно картинка формата png, указываем Content-Type как image/png. Затем читаем изображение и получаем первый параметр social_network — он пригодится, если вы хотите в зависимости от социальной сети вставлять разные фоновые изображения. Загружаем картинку и получаем второй параметр — text. Все пробелы будут переформатированы в «%20», поэтому методом str_replace возвращаем всё обратно. Время чтения — параметр time_for_read, а time_ending — слово, которое нужно подставить после числа минут. Далее загружаем нужные шрифты.

Так как параметры принимаются через ссылку, в теории, туда можно передать любой SQL-запрос — это называется SQL-инъекцией. Такую брешь можно устранить, если все числовые переменные ещё раз перевести в числовые типы данных, а в строках экранировать кавычки: для этого есть функция mysql_escape_string(). А ещё в строку могут передать HTML-теги — для удаления тегов можно воспользоваться функцией strip_tags().

Функция mysql_escape_string() ещё работает в php5, но была удалена в php7. Подробнее об альтернативах можно почитать в документации

header('Content-Type: image/png');

$social_network = $_GET['social_network'];
$social_network = mysql_escape_string(strip_tags($social_network));

$img = LoadPNG('og-image-vk.png');
$text = $_GET['text'];
$text = mysql_escape_string(strip_tags($text));
str_replace('%20', ' ', $text);
$time_for_read = $_GET['time_for_read'];
$time_for_read = intval($time_for_read);
$time_ending = $_GET['time_ending'];
$time_ending = mysql_escape_string(strip_tags($time_ending));
$tahoma_bold_font_path = '/og-fonts/Tahoma-Bold.ttf';
$tahoma_regular_font_path = '/og-fonts/Tahoma-Regular.ttf';
$prosto_font_path = '/og-fonts/ProstoOne-Regular.ttf';

Задаём нужные цвета для текста и подставляем текст на изображение. Функцией imagepng выводим изображение, а затем его удаляем для освобождения памяти.

$white_color = imagecolorallocate($img, 255, 255, 255);
$mentol_color = imagecolorallocate($img, 135, 244, 191);
imagettftextjustified($img, 45, 0, 55, 200, $white_color, $prosto_font_path, $text, 1000, 10, 1.2);
imagettftextjustified($img, 15, 0, 110, 63, $mentol_color, $prosto_font_path, "Время чтения – ".$time_for_read." ".$time_ending, 1000, 10, 1);
imagepng($img);
imagedestroy($img);
?>

После размещения скрипта на сайте можно сразу протестировать его работу: так как наш лежит на сайте leftjoin.ru в директории og-image-files, можно пройти по ссылке leftjoin.ru/og-image-files/og-image.php и увидеть следующее изображение:

Оно пустое, так как мы не передали параметров. Они передаются непосредственно в ссылку. Например, передача текста и наименования социальной сети выглядит так:

https://leftjoin.ru/og-image-files/og-image.php?text=Hello%20world&social_network=vk

Настраиваем теги протокола Open Graph

Откроем файл с тегом <header> сайта. В случае Эгеи это system/theme/templates/head.tmpl.php. Кое-какие теги предварительно размечены: например, в цикле все изображения отмечаются как og-image, что позволяет при публикации материала в социальных сетях выбирать из карусели картинку для сниппета.

А мы структурируем все теги и собираем вместе под комментарием «OPEN GRAPH PROTOCOL». В Эгее все нужные данные по каждой статье уже лежат в переменных, например, заголовок материала лежит в $content[’title’], а краткое описание страницы можно получить функцией array_keys(content). Главное для нас — добавить такую строку:

<meta property="og:image" content='https://leftjoin.ru/og-image-files/og-image.php?text=<?=urlencode($content['title'])?>&time_for_read=<?=urlencode($time_for_read)?>&time_ending=<?=urlencode($time_ending)?>' />

Она задаёт тег og-image и принимает в content изображение. Туда мы подставляем ссылку на скрипт со всеми параметрами-переменными, обработав их функцией urlencode — она и заменяет все пробелы на «%20». Если ей не воспользоваться, то текст на изображениях в социальных сетях вроде ВКонтакте, Facebook, Twitter и даже в мессенджере Telegram будет отображаться нормально, а вот в Slack вместо пробелов останутся символы «%20», даже с учётом их обработки в скрипте ранее. Должен получиться похожий блок:

<!-- OPEN GRAPH PROTOCOL-->
<meta name="og:description" content="<?array_keys(content)?>" />
<meta property="og:image" content='https://leftjoin.ru/og-image-files/og-image.php?text=<?=urlencode($content['title'])?>&time_for_read=<?=urlencode($time_for_read)?>&time_ending=<?=urlencode($time_ending)?>' />
<?php foreach ($content['og-images'] as $image): ?>
<meta property="og:image" content="<?=$image?>"/>
<?php endforeach ?>
<meta property="og:image:type" content="image/png" />
<meta name="vk:image" content='https://leftjoin.ru/og-image-files/og-image.php?text=<?=urlencode($content['title'])?>&time_for_read=<?=urlencode($time_for_read)?>&time_ending=<?=urlencode($time_ending)?>&social_network=vk' >
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:image" content='https://leftjoin.ru/og-image-files/og-image.php?text=<?=urlencode($content['title'])?>&time_for_read=<?=urlencode($time_for_read)?>&time_ending=<?=urlencode($time_ending)?>' />
<meta name="viewport" content="<?= $content['meta-viewport'] ?>">
<title><?= $content['title'] ?></title>
<meta name="og:title" content="<?= $content['title'] ?>" />
<meta property='og:type' content="article" />
<meta name="og:url" content="<?= $content['current-href'] ?>" />

В результате мы написали скрипт, который генерирует изображение с текстом-параметром прямо на сайте, а затем вызвали этот скрипт в тегах протокола Open Graph. В качестве доработки можно добавить изображения разных размеров и генерировать картинку в зависимости от социальной сети. Теги тоже могут отличаться — изображение для ВКонтакте вставляется в тег <vk:image>, а для Twitter — <twitter:image>. В примере выше указано правильное использование этих тегов.

Как создавать дашборды, используя подход продуктивного мышления

Время чтения текста – 15 минут

Этот материал — перевод статьи «How to Make Dashboards Using a Product Thinking Approach»

Ни для кого не секрет, что передача результатов исследований другим людям — важнейшая часть науки о данных. Один из инструментов, который мы часто используем в Shopify — дашборды. Этот пост — пошаговое руководство по созданию дашбордов, ориентированных на пользователя и результат.

Люди используют слово «дашборд» для обозначения разных вещей. В этом посте я сужаю своё определение до автоматически обновляемого набора визуализаций данных и бизнес-показателей.

К сожалению, если вы несерьёзно относитесь к процессу создания дашборда, получится так, что вы вложили много усилий в создание продукта, который не имеет реальной ценности. Дашборд, который никто не использует полезен примерно так же, как барахло из магазинов на диване. Итак, как вы можете убедиться том, что ваш дашборд удовлетворяет запросам пользователя?

Ключевой момент — продуктовое мышление. Это неотъемлемая часть отдела Data Science в Shopify. Как мы создаём продукты, думая о наших продавцах, так и специалисты по обработке данных создают дашборды, ориентированные на потребности аудитории.

Нужен ли мне дашборд?

Прежде чем мы погрузимся в создание дашбордов, первое, о чём вы должны спросить себя — подходит ли этот инструмент вам. Есть множество других способов передачи данных, включая длинные отчёты и презентации. Создание и обслуживание дашборда может занять много времени, и вам ни к чему тратить силы без необходимости.

Вопросы, которые следует задать себе:

  1. Будут ли данные дашборда обновляться динамически?
  2. Хотите ли вы, чтобы исследование было интерактивным?
  3. Ваша цель заключается в том, чтобы мониторить что-то и отвечать на вопросы, связанные с данными?
  4. Нужно ли пользователю возвращаться к этим данным ввиду их ежедневного изменения?

Если на большинство вопросов вы ответили «Да», то дашборд — хороший выбор для решения вашей проблемы.

Иначе, если ваша цель — призыв пользователя к действию, дашборд — не лучший выбор. Дашборды удобны, потому что они автоматически представляют обновляемые метрики и визуализации. Если вы хотите рассказать историю, чтобы повлиять на аудиторию, вам лучше поработать с историческими статическими данными в отчёте или презентации.

1. Поймите проблему и аудиторию

После того, как вы приняли решение создать дашборд, вам нужно определить цель и аудиторию. Можете начать с подобной таблицы:

Аудитория Цель
Команда обработки данных Решить, нужно ли отправлять экспериментальную фичу всем нашим продавцам
Руководство Мониторить влияние COVID-19 на продавцов в розничных магазинах
Продуктовая команда Обнаружить изменения в поведении пользователей после внедрения новой фичи

Может получиться так, что для одной аудитории у вас больше одной цели. Это означает, что вам нужно больше одного дашборда.

Четко определив свою аудиторию и причину создания дашборда, вам нужно выяснить, какие показатели лучше всего удовлетворяют потребностям группы. В большинстве случаев это неочевидно и может превратиться в долгую беседу с пользователем, и это нормально! Время, потраченное на данном этапе, принесёт плоды позже.

Хорошие показатели — те, которые тщательно отобраны с учётом поставленных целей. Если ваша цель — отслеживание аномалий, вам необходимо включить широкий спектр метрик и визуализаций с заданными пороговыми значениями. Если вы хотите, чтобы дашборд показывал, насколько успешен ваш продукт, вам нужно подумать о небольшом количестве KPI, которые являются показателями реальной ценности.

После того, как вы определитесь с показателями и визуализацией данных, составьте приблизительный план того, как они будут представлены: это может быть электронная таблица, или что-то более наглядное — эскиз на доске или в даже ежедневнике. Покажите его целевой группе, прежде чем писать код: важно убедиться, что ваше предложение поможет решить их проблему.

Пример макета дашборда. Визуальное представление способствует быстрому согласованию

Теперь, когда у вас есть план, вы готовы приступить к созданию дашборда.

2. Помните о своих пользователях

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

Когда дело доходит до точности и эффективности, вам, вероятно, придётся написать код или запросы для создания показателей или визуализации на основе ваших данных. В Shopify при написании кода мы всегда следуем лучшим методам работы с программным обеспечением.

  1. Придерживайтесь единых стандартов оформления, чтобы сделать запросы более читабельными
  2. Оптимизируйте запросы, чтобы сделать их максимально эффективными
  3. Пользуйтесь системами контроля версий, чтобы отслеживать изменение кода в процессе разработки
  4. Получите обратную связь по дашборду для обмена контекстом

Способ представления данных напрямую влияет на понимание данных пользователем.

Используйте макет, чтобы сосредоточить внимание пользователей

Как и на первой полосе газеты, вашим пользователям нужно узнать самую важную информацию в первые несколько секунд. Один из способов сделать это — структурировать дашборд в виде перевернутой пирамиды, у которой вверху самые «сочные» заголовки, в середине — важные детали, а внизу — общая, но не менее важная справочная информация.

Перевернутая пирамида — пример организации иерархии информации, которую вы отражаете на дашборде

Не забудьте использовать исходные цели из первого этапа при формировании иерархии.

Делайте макет логичным и простым. Провожайте взгляд пользователя по странице, используя последовательную визуальную иерархию заголовков и разделов. Сгруппируйте вместе связанные показатели, чтобы их было легко найти.

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

Не бойтесь добавлять свободное пространство — оно даёт пользователям передышку улучшает понимание информации.

Оставляйте только целевой контент

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

Будьте смелыми и удалите все визуализации или KPI, не имеющие прямого отношения к вашим целям. Лишние подробности скрывают важные факты под беспорядком. Если вам все равно кажется, что они нужны, подумайте о создании отдельного дашборда для вторичного анализа.

Убедитесь, что ваш дашборд включает бизнес-контекст и контекст данных

Обеспечьте достаточный бизнес-контекст, чтобы кто-то, открывший ваш дашборд, мог сразу получить ответы на такие вопросы:

  1. Почему существует этот дашборд
  2. Для кого он создан
  3. Когда он был построен и когда он перестанет быть актуальным
  4. Какие функции он реализует

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

Статистика справа лучше, чем слева, потому что преподносит контекст.

Контекст можно предоставить и другим путём — например, наладив сегментацию или фильтрацию данных. Различные сегменты могут давать результаты с совершенно противоположными значениями.

Перед публикацией подумайте об актуальности данных

Свежесть дашборда зависит от актуальности приведённых данных, поэтому подумайте о том, как часто обновляется информация. Перед отправкой лучше всего получить как минимум два технических обзора и одобрение предполагаемых пользователей. В конце концов, если они не понимают дашборд или не видят в нём ценности, они не будут им пользоваться.

3. Поддержка

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

«Продавайте» дашборд

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

Перед запуском подумайте, как вы можете представить всем вашу работу. У вас будет только один шанс сделать это, поэтому действовать надо осознанно. Например, вы можете подготовить сопроводительное руководство по использованию дашборда в виде короткого пошагового видео.

В долгосрочной перспективе убедитесь, что после запуска дашборд легко найти каждому, кто может в нём нуждаться. Можно разместить его на каких-нибудь внутренних порталах и использовать заголовки и теги, адаптированные к общим условиям поиска. Не бойтесь кричать о своем дашборде в подходящие моменты.

Используйте и улучшайте

Вернитесь к первоначальным целям и подумайте, как их достичь. Например, если задача дашборда — понять, стоит ли внедрять новую фичу, будьте готовы в момент принятия решения поделиться с коллегами своим мнением на основе данных дашборда.

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

Если дашборд не дал желаемого результата, выясните, что пошло не так. Есть ли что-то, что вы могли бы изменить, чтобы сделать его полезнее? Используйте это исследование, чтобы улучшить следующий дашборд.

Поддерживайте

Наконец, как и в случае с любой информационной системой, без надлежащего обслуживания дашборд придёт в негодность. Назначьте специалиста по данным или группу специалистов, которые ответят на вопросы или исправят возникшие проблемы.

Ключевые выводы

Посмотрите и другие наши переводы — «10 правил для совершенного дизайна дашбордов» и «Полное руководство по созданию таблиц»

Теперь вы знаете, как разбить процесс создание дашборда с помощью продуктового мышления. Резюмируя, вы можете использовать продуктовый подход к созданию впечатляющего дашборда, выполнив следующие действия:

  1. Понять проблему и аудиторию, спроектировать дашборд, который хорошо справляется с одной задачей для чёткого круга пользователей
  2. Учесть интересы пользователей, чтобы он был точным и простым для понимания
  3. Поддерживать полученный результат, продвигая и улучшая его в дальнейшем

Выполнив эти три шага, вы создадите дашборд, который будет в центре внимания вашей аудитории.

Обзор Looker

Время чтения текста – 3 минуты

Looker — BI-инструмент класса self-service. Это подразумевает, что все отчёты и быструю аналитику пользователь делает самостоятельно без привлечения специалиста в области данных (последний заранее настраивает необходимые модели данных).

Looker особенно популярен в США: в 2019 году Google купил стартап за $2,6 млрд. Тем не менее, далеко не каждый российский аналитик с ним знаком. В рунете ещё не было обзора на Looker, так что заложим фундамент для последующих публикаций.

В сегодняшнем обзоре BI-систем мы изучим интерфейс Looker, погрузимся в терминологию инструмента, взглянем на готовые приложения в Marketplace, разберёмся с построением Look ML моделей и посмотрим на итоговый дашборд по датасету SuperStore.

Подробнее об инструменте можно почитать в материале «Обзор Looker»

Публикация дашборда

При публикации дашборда таким методом он может некорректно отображаться в браузерах Safari и Internet Explorer

Для публикации мы использовали подход, описанный в документации Looker. Генерация ссылки происходит как в примере с GitHub.

Предварительно выполняем создание нового пользователя в настройках админ-панели Looker с соответствующими просмотру дашборда доступами, чтобы любой незарегистрированной пользователь мог войти под этой учётной записью в одной сессии. Для вывода дашборда на веб-страницу используется фреймворк Flask, а сама сгенерированная ссылка вставляется как источник в тег iframe в html-файле. Весь код деплоим на Heroku, чтобы иметь постоянный URL для доступа к дашборду.

Так как ссылка для SSO генерируется для одной сессии, нужно настроить Heroku Scheduler и прописать выполнение скрипта такого вида соответственно длине одной сессии. Например, если сессия длится 10 минут, то и выполнение должно происходить каждые 10 минут.

Оценки

Внутри команды мы оценили дашборд и получили следующие средние оценки (1 — худшая оценка, 10 — лучшая):
1) Отвечает ли заданным вопросам — 8,8
2) Порог входа в инструмент — 7
3) Функциональность инструмента — 7,4
4) Удобство пользования — 7,2
5) Соответствие результата макету — 7,8
6) Визуальная составляющая — 8,6
Итог: дашборд в Looker получает 7,8 баллов из 10.

Посмотрите на полученный результат.

Ранее Ctrl + ↓