<?xml version="1.0" encoding="utf-8"?> 
<rss version="2.0">

<channel>

<title>Блог об аналитике, визуализации данных, data science и BI, заметки с тегом: Data Analytics</title>
<link>http://test.leftjoin.ru/tags/data-analytics/</link>
<description></description>
<generator>E2 (v3365; Aegea)</generator>

<item>
<title>Сквозной идентификатор: решение проблемы мэтчинга персональных данных студентов Refocus</title>
<guid isPermaLink="false">148</guid>
<link>http://test.leftjoin.ru/all/skvoznoy-identifikator-reshenie-problemy-metchinga-personalnyh-d/</link>
<comments>http://test.leftjoin.ru/all/skvoznoy-identifikator-reshenie-problemy-metchinga-personalnyh-d/</comments>
<description>
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/cover.png  border=“0” width=100% height=100%&gt;&lt;/p&gt;
&lt;p&gt;В системах сквозной аналитики ключевую роль играет правильная модель атрибуции. Без нее данные невозможно интерпретировать, и их ценность для бизнеса невелика. При этом важно понимать, что любая модель напрямую зависит от качества данных.&lt;/p&gt;
&lt;p&gt;Частая проблема с сырыми данными в том, что информация об одном клиенте дублируется или, напротив, противоречит друг другу в разных источниках.&lt;/p&gt;
&lt;p&gt;Кроме того, что предобработка данных — база для аналитика, без правильного объединения персональных данных в принципе сложно отследить клиентский путь. Значит, нужно настраивать процессы объединения неоднородных персональных данных.&lt;/p&gt;
&lt;p&gt;Сегодня в любом клиентском бизнесе воронки регистрации устроены таким образом, что клиенты попадают в базу множеством способов — часто через маркетинговые каналы, которых всегда много (рассылки, реклама, соцсети). В каждом таком канале может быть ссылка на форму подписки, регистрацию на платформе или чат, и один клиент часто проходит все эти этапы. Сразу же образуется путаница в идентификации, которая сильно влияет на качество данных и результаты аналитики, если ее не лечить.&lt;/p&gt;
&lt;p&gt;Мы столкнулись с этой проблемой, работая с одним из наших клиентов, и решили ее, создав сквозной идентификатор. Это уникальный номер, который присваивается реальному клиенту и дублируется во все источники, где есть данные об этом клиенте, тем самым избавляя от путаницы.&lt;/p&gt;
&lt;h2&gt;Кейс Refocus: данные и путь клиента&lt;/h2&gt;
&lt;p&gt;Мы разрабатывали кастомную систему сквозной аналитики для эдтех-стартапа Refocus. Данные каждого студента в системы Refocus попадали из нескольких источников и были записаны несколько раз — как минимум при регистрации на курс, при первом входе на образовательную платформу и при входе в чат сопровождения.&lt;/p&gt;
&lt;p&gt;В нашем случае мэтчинг был важнее всего по трем источникам из тринадцати:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;b&gt;amoCRM,&lt;/b&gt; где фиксируется весь клиентский путь студента;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Discord,&lt;/b&gt; где проходило сопровождение студентов;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Thinkific,&lt;/b&gt; сама образовательная платформа с курсами.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Остальные источники, с которыми мы работали, либо не содержали данных студентов (например, цифры эффективности работы sales-менеджеров были завязаны на данных сотрудников и трекались через другие системы), либо дублировали информацию из указанных трех.&lt;/p&gt;
&lt;p&gt;В Discord и Thinkific данные попадали напрямую, от студентов при регистрации в системах, а затем подтягивались в amoCRM. Основные причины несовпадения клиентских данных как у Refocus, так и в похожих случаях — человеческий фактор (опечатки), наличие у людей более чем одного телефона или адреса почты и ограничения самих платформ, с которых приходят данные: разный заданный формат полей и их количество.&lt;/p&gt;
&lt;p&gt;Часть этих факторов может решаться корректировкой самой клиентской воронки. Правда, не все платформы позволяют одинаково настроить вводные поля, а просьбы вводить данные в конкретном формате не всегда работают и не страхуют от ошибок. Плюс, задача аналитиков — получить чистые данные в любом случае.&lt;/p&gt;
&lt;h2&gt;Задача и поиск решения&lt;/h2&gt;
&lt;p&gt;Данные в Refocus мы подгружали в хранилище в BigQuery напрямую из интересующих нас источников (рекламных кабинетов, LMS и т. д.), используя Python. В дальнейшем на этих данных строились дашборды в Tableau.&lt;/p&gt;
&lt;p&gt;Обнаружить проблему несложно — при создании хранилища и дальнейшей выгрузке данных из него мы в любом случае чистим датасет от дубликатов и несовпадений.&lt;/p&gt;
&lt;p&gt;Поля, в которых возникали ошибки и для которых нам важен был мэтчинг, чтобы правильно отследить клиентский путь:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;имя — да, люди иногда вводят разные вариации ФИО (Юлия, Юля и Бля — на деле один человек!);&lt;/li&gt;
&lt;li&gt;телефон — с кодом страны или без, с пробелами, дефисами или слитно;&lt;/li&gt;
&lt;li&gt;электронная почта — длинные строки сложного формата, в которых легко опечататься.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Поначалу, пока количество студентов Refocus было относительно небольшим, достаточно было скриптов, которые объединяли данные по одному из этих полей. В полученных таблицах в Tableau проводился поиск строк с пустым значением в соответствующем поле — и вот видно всех студентов, чьи данные не сошлись.&lt;/p&gt;
&lt;p&gt;Количество таких строк было в пределах пары десятков, и трекать и объединять их было несложно вручную. Это делалось прямо в первоисточниках сотрудниками Refocus, которые могли поправить опечатки и ошибки у себя в системах. После этого наш код выгрузки в хранилище перезапускался и тянул уже чистые данные. Если после этого что-то не сходилось, то наши аналитики правили информацию на уровне базы данных.&lt;/p&gt;
&lt;p&gt;Но при росте компании в какой-то момент число студентов, потерянных при мэтчинге, могло достигать сотни за месяц. Пока ошибка обнаружится, данные поправят в источниках, а мы перезапустим код выгрузки, могло пройти несколько часов — а это критичный интервал. Да и перезапускать выгрузку каждый день ради нескольких несовпадений — неэффективно. Стало понятно, что масштаб проблемы требует более точного и универсального решения.&lt;/p&gt;
&lt;p&gt;Вообще, в такой ситуации возможны несколько вариантов. Можно бесконечно править скрипты мэтчинга, учитывая новые и новые случаи и создавая костыли. А можно, например, настроить алерты в оркестраторе процессов (в нашем случае  — Airflow), которые позволят моментально узнавать о появившемся несовпадении и объединять “потерянные” клиентские сущности по паре за раз. Но это все еще неполная автоматизация, и она только ускоряет, а не упрощает процесс.&lt;/p&gt;
&lt;p&gt;Руководствуясь соображениями эффективности, мы предложили ввести сквозной идентификатор — одно значение ID, присваиваемое одному клиенту после автоматической интеграции его данных из разных источников.&lt;/p&gt;
&lt;h2&gt;Реализация решения и рабочий процесс&lt;/h2&gt;
&lt;p&gt;Чтобы понять масштаб проблемы, мы начали с того, что создали таблицы несовпадающих персональных данных. Для этого мы использовали скрипты на Python. Эти скрипты объединяли данные из разных источников и создавали из них большую сводную таблицу. Для того, чтобы свести данные о студенте в одну сущность, использовался мэтчинг по адресу электронной почты. Мы попробовали мэтчить по имени, фамилии, телефону (который сначала надо было привести к одному формату!) и почте, и именно последний вариант показал самую высокую точность. Возможно, дело в том, что из всех данных почта имеет самый однородный формат, поэтому остается учитывать только опечатки.&lt;/p&gt;
&lt;p&gt;Например, нам нужно было мэтчить данные для создания дашборда по возвратам, о которых информация объединялась как раз из наших трех основных источников. В ранней версии скрипта данные отбирались таким образом:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;WITH snapshot_ AS (
      SELECT DISTINCT s.*,
        IFNULL(ae.name, ap.name) as contact_name,
        ap.phone, ae.email,
        split(replace(trim(lower(ae.email)),' ',''),'@')[OFFSET(0)] as email_first_part,
        ai.thinkific_id, ai.intercom_id, ac.student_id
      FROM (
        SELECT *,
          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY updated_at DESC) as num_,
        FROM `Differture.amocrm_leads_snapshot`
      ) s
      LEFT JOIN (
        SELECT DISTINCT lead_id, contact_id, name, email,
          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY contact_id) as num1_
        FROM `Differture.amo_emails`
      ) ae using(lead_id)
      LEFT JOIN (
        SELECT DISTINCT lead_id, contact_id, name, phone,
          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY contact_id) as num2_
        FROM `Differture.amo_phones`
      ) ap using(lead_id)
      LEFT JOIN `Differture.amo_contact_thinkific_intercom_match` ai using(lead_id)
      LEFT JOIN `Differture.AmoContacts` ac on cast(ae.contact_id as string)=ac.amo_id
      WHERE (num_=1 or num_ is null) and (num1_=1 or num1_ is null) and (num2_=1 or num2_ is null)
        and s.pipeline_id in (4920421,5245535) and s.status_id=142 and lower(s.lead_name) not like '%test%'
    )&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Как можно заметить, идея сквозного идентификатора здесь уже присутствует — фигурирует &lt;tt&gt;student_id&lt;/tt&gt;. На самом деле, в этой версии скрипта это графа из AmoContacts — таблицы, в которой хранятся только данные из amoCRM. Никаких джойнов по &lt;tt&gt;student_id&lt;/tt&gt; пока не происходит. А происходят по &lt;tt&gt;email_first_part&lt;/tt&gt;, адресу почты до символа @:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;select distinct * from th_amo_ds_rf
    left join calendly ce using(email_first_part)
    left join typeform_live tfl on email_first_part=tf_email_first_part
    left join typeform tf using(email_first_part)
    left join csat using(email_first_part)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Первым шагом по практическому введению идентификатора была таблица &lt;b&gt;students_main_info&lt;/b&gt;, созданная в BigQuery in-house специалистом Refocus. К сожалению, у нас нет доступа к коду, который использовался для присвоения идентификатора. Зато мы можем показать вид этой таблицы:&lt;/p&gt;
&lt;div class="e2-text-table"&gt;
&lt;table cellpadding="0" cellspacing="0" border="0"&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_full_name&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_email&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_country_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_country_name&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_courses_ids&lt;/td&gt;
&lt;td style="text-align: center"&gt;array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_courses_names&lt;/td&gt;
&lt;td style="text-align: center"&gt;array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_cohort_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_cohort_name&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;cohort_community_manager_name&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;cohort_community_manager_email&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_onboarding_live_session_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_onboarding_live_session_time&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_onboarding_live_session_zoom_url&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;amo_contact_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;intercom_contact_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;thinkific_student_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;discord_user_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;discord_user_discord_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;discord_guild_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;discord_channel_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;discord_roles&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;В students_main_info хранились данные из нужных источников с общим идентификатором в первой строке, и объединение проходило через сравнение этого поля.&lt;/p&gt;
&lt;p&gt;При этом поле &lt;tt&gt;student_id&lt;/tt&gt; использовалось пока не везде; также использовались другие поля этой таблицы — например, &lt;tt&gt;thinkific_student_id&lt;/tt&gt; или &lt;tt&gt;discord_user_id&lt;/tt&gt;.&lt;/p&gt;
&lt;p&gt;После выгрузки и мэтчинга данных с помощью students_main_info студентов, которые потерялись при объединении, стало меньше, чем при первой схеме мэтчинга. Так мы убедились, что движемся в верном направлении. Тем не менее, использование одной таблицы, которая содержит больше десятка полей обо всех имеющихся персональных данных, не очень эффективно. Данные в ней уже обработаны скриптом специалиста Refocus, и если надо сверить их с сырыми источниками или ввести новый критерий отслеживания, все придется менять на бэкенде.&lt;/p&gt;
&lt;h2&gt;Что получилось в итоге&lt;/h2&gt;
&lt;p&gt;После теста сквозного идентификатора через одну большую таблицу мы продолжили улучшать структуру данных на бэке. Вместо students_main_info усилиями специалиста Refocus появилась подробная сеть более мелких таблиц, которые могут обращаться друг к другу и лежат в одном хранилище с нашими таблицами сырых данных.&lt;/p&gt;
&lt;p&gt;Вот так выглядела схема соотношения этих таблиц:&lt;/p&gt;
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/image-2.png  border=“0” width=100% height=100%&gt;&lt;/p&gt;
&lt;p&gt;А вот так выглядела основная таблица Students:&lt;/p&gt;
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/students.png border=“0” width=40% height=40%&gt;&lt;/p&gt;
&lt;p&gt;В ней-то и находились основные персональные данные студентов с присвоенным идентификатором, и к ней можно было обращаться для мэтчинга из остальных источников.&lt;/p&gt;
&lt;p&gt;Остальные таблицы выглядели похоже: всегда было поле с идентификатором и информация о какой-то характеристике студента — когорта, курс, роль в дискорде и так далее.&lt;/p&gt;
&lt;p&gt;Финальный код, написанный нашими аналитиками,  объединял данные при выгрузке из хранилища, и больше не опирался на ненадежный мэтчинг через имейл.&lt;/p&gt;
&lt;p&gt;Сначала он отбирал собранные нами данные из amoCRM &lt;tt&gt;(amocrm_leads_snapshot)&lt;/tt&gt; и объединял их с контактной информацией клиентов. Затем в таблицу добавлялось поле &lt;tt&gt;student_id&lt;/tt&gt; и отбирались данные, которые понадобятся нам дальше.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;WITH snapshot_ AS (
      SELECT DISTINCT s.*,
        ac.name as contact_name, ac.phone, ac.email,
        split(replace(trim(lower(ac.email)),' ',''),'@')[OFFSET(0)] as email_first_part,
        ac.intercom_id, ac.student_id
      FROM (
        SELECT *,
          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY updated_at DESC) as num_,
        FROM `Differture.amocrm_leads_snapshot`
      ) s
      LEFT JOIN (
        select cast(al.amo_id as INT64) as lead_id, cast(ac.amo_id as INT64) as contact_id,
          ac.name, emails as email, phone, student_id, ic.intercom_id,
          ROW_NUMBER() OVER(PARTITION BY al.amo_id ORDER BY ac.amo_id) as num1_
        from `Differture.AmoContacts` ac
        left join `Differture.AmoLeads` al on al.amo_contact_id=ac.id
        left join `Differture.IntercomContacts` ic using(student_id)
        , unnest(ac.emails) emails
      ) ac using(lead_id)
      WHERE (num_=1 or num_ is null) and (num1_=1 or num1_ is null)
        and s.pipeline_id in (4920421,5245535) and s.status_id=142 and lower(s.lead_name) not like '%test%'
    )&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь при создании общей таблицы о возвратах с данными из amo, Thinkific и Discord объединение проходило через student_id:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;th_amo_ds_rf as (
      select distinct * except (channel_id, channel),
        ifnull(channel_id, 'Not in discord') as channel_id,
        ifnull(channel, 'Not in discord') as channel
      from thinkific_amo_refunds
      full outer join discord using(student_id)
    )&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Когда объединенные таблицы данных студентов были созданы, получить таблицы несовпадений можно было простой строкой кода в Tableau:&lt;/p&gt;
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/filter.png border=“0” width=50% height=50%&gt;&lt;/p&gt;
&lt;p&gt;Пустое значение поля student_id означает, что мэтча не случилось — где-то информация расходилась слишком сильно и не подтянулась в таблицы с идентификатором. Раньше, до введения идентификатора, поиск был таким же, но обращался к полям почты, телефона или имени-фамилии.&lt;/p&gt;
&lt;p&gt;Ниже можно увидеть таблицу, где данные из Thinkific не совпадали с amoCRM после перехода на Student ID. В этом случае студент есть в LMS, значит, на курсе учится — но его либо нет в системе учета, либо данные в ней разнятся с LMS.&lt;/p&gt;
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/unnamed.png  border=“0” width=100% height=100%&gt;&lt;/p&gt;
&lt;p&gt;А вот таблица, где данные из Discord не совпадали с amoCRM. Все так же, как выше — студент есть в чатах сопровождения, но не ищется по своим данным в amoCRM.&lt;/p&gt;
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/discord.png  border=“0” width=100% height=100%&gt;&lt;/p&gt;
&lt;p&gt;Оба скриншота показывают количество несовпадений примерно за месяц. Как видно по этим таблицам, количество несовпадений уменьшилось с 80-90 до пары десятков — примерно на 75%. Это позволило сократить количество перезапусков кода выгрузки вручную и уменьшить затраты времени и технических ресурсов на поддержание системы.&lt;/p&gt;
&lt;h2&gt;Выводы&lt;/h2&gt;
&lt;p&gt;Сквозной идентификатор — эффективное решение проблемы мэтчинга персональных данных. Он позволяет максимально автоматизировать процесс отслеживания и устранения несовпадений или дубликатов клиентских сущностей при выгрузке данных для анализа. В случаях, когда объем данных в системе невелик, а у компании нет возможности выделить ресурсы на реализацию такого решения, можно воспользоваться и другими вариантами. Например, алерты в оркестраторе процессов хорошо справятся в ситуации, когда объединить данные — вопрос ручного запуска одного скрипта раз в неделю. Но сквозной идентификатор — наверное, самое универсальное из доступных решений, которое покроет большинство ошибок и заметно уменьшит погрешность в качестве данных.&lt;/p&gt;
</description>
<pubDate>Mon, 16 Sep 2024 17:57:31 +0300</pubDate>
</item>

<item>
<title>Анализируем речь с помощью Python: Сколько раз в минуту матерятся на интервью YouTube-канала «вДудь»?</title>
<guid isPermaLink="false">136</guid>
<link>http://test.leftjoin.ru/all/youtube-interview-speech-analysis/</link>
<comments>http://test.leftjoin.ru/all/youtube-interview-speech-analysis/</comments>
<description>
&lt;p&gt;&lt;img src="http://test.leftjoin.ru/pictures/13-1.jpg" border="0" width="100%" height="100%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Выход практически каждого ролика на канале «вДудь» считается событием, а некоторые из этих релизов даже сопровождаются скандалами из-за неосторожных высказываний его гостей.&lt;br /&gt;
Сегодня при помощи статистических подходов и алгоритмов ML мы будем анализировать прямую речь. В качестве данных используем интервью, которые журналист Юрий Дудь (признан иностранным агентом на территории РФ) берет для своего YouTube-канала. Посмотрим с помощью Python, о чем таком интересном говорили в интервью на канале «вДудь».&lt;/p&gt;
&lt;h2&gt;Сбор данных&lt;/h2&gt;
&lt;p&gt;C помощью &lt;a href="https://developers.google.com/youtube/v3"&gt;YouTube API&lt;/a&gt; мы получили список всех видео с канала Юрия Дудя, а также их метаинформацию. О том, как это сделать, вы можете узнать, например, из &lt;a href="http://test.leftjoin.ru/all/youtube-api/"&gt;статьи нашего блога&lt;/a&gt;.&lt;br /&gt;
Если вы уже слышали знаменитое “Юрий будет дуть, дуть будет Юрий”, то наверняка знаете, что на этом канале есть документальные фильмы, а также интервью, в которых участвуют сразу несколько гостей. Нас заинтересовали только те выпуски, в которых преимущественно говорит только один гость. Поэтому нам пришлось провести фильтрацию всех видео вручную.&lt;br /&gt;
Для дальнейшего анализа нам необходимо было получить длительности роликов. Это мы сделали с помощью GET-запросов к YouTube API. Результаты приходили в специфическом формате (для примера: “PT1H49M35S”), поэтому их нам пришлось распарсить и перевести в секунды.&lt;br /&gt;
Итак, мы получили датафрейм, состоящий из 122 записей:&lt;/p&gt;
&lt;p&gt;&lt;img src="http://test.leftjoin.ru/pictures/--1.png" border="0" width="100%" height="100%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;На основе метаинформации по лайкам, комментариям и просмотрам мы построили следующий Bubble Chart:&lt;/p&gt;
&lt;iframe width="730" height="480" frameborder="0" scrolling="no" src="//plotly.com/~satiukov.e/29.embed?showlink=false"&gt;&lt;/iframe&gt;
&lt;p&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Так как наша цель — проанализировать речь в интервью, нам необходимо было получить текстовые составляющие роликов. В этом нам помог API-интерфейс &lt;a href="https://pypi.org/project/youtube-transcript-api/"&gt;youtube_transcript_api&lt;/a&gt;, который скачивает субтитры из видео на YouTube. Для каких-то роликов субтитры были прописаны вручную, но для большинства они были сгенерированы автоматически. К сожалению, для 10 видео субтитров не оказалось: беседы с L’one, Шнуром, Ресторатором, Амираном, Ильичом, Ильей Найшуллером, Соболевым, Иваном Дорном, Навальным, Noize MC. Причину их отсутствия мы, к сожалению, понять не смогли.&lt;/p&gt;
&lt;h2&gt;А гости кто?&lt;/h2&gt;
&lt;p&gt;Спектр рода деятельности гостей канала «вДудь» достаточно обширен, поэтому было решено пополнить исходные данные информацией о том, чем же в основном занимается приглашенный участник каждого интервью. К сожалению, ролики не сопровождаются четкими метками профессиональной принадлежности гостя, поэтому мы прописали эту информацию сами. На момент выгрузки данных последним видео на канале был разговор с комиком Дмитрием Романовым.&lt;br /&gt;
Если с идентификацией профессии каждого гостя мы не ошиблись, то вот такое распределение в итоге получается:&lt;/p&gt;
&lt;iframe width="730" height="480" frameborder="0" scrolling="no" src="//plotly.com/~satiukov.e/31.embed?showlink=false"&gt;&lt;/iframe&gt;
&lt;p&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Музыканты, рэперы и актеры — самые частые гости Юрия, скорее всего, они являются самыми интересными для автора и аудитории. Представителей научного сообщества (астрофизик, историк, экономист и т.д) наоборот, гораздо меньше, ведь научно-популярные интервью — прерогатива других интервьюеров.&lt;/p&gt;
&lt;h2&gt;Обработка текста&lt;/h2&gt;
&lt;p&gt;Анализ текстовой информации сложен в той степени, в какой сложен язык, на котором написан текст. Подробно о подготовке текста к анализу мы рассказывали в материале &lt;a href="http://test.leftjoin.ru/all/borderline-text-analysis/" class="nu"&gt;«&lt;u&gt;Python и тексты нового альбома Земфиры&lt;/u&gt;»&lt;/a&gt;. Тут была проведена идентичная работа.&lt;br /&gt;
Как и раньше, для решения аналитической задачи мы решили использовать такой подход как лемматизация, т. е. приведение слова к его словарной форме. Проведя лемматизацию текстовых данных по правилам русского языка, мы получим существительные в именительном падеже единственного числа (кошками — кошка), прилагательные в именительном падеже мужского рода (пушистая — пушистый), а глаголы в инфинитиве (бежит — бежать). В этом проекте мы опять воспользовались библиотекой &lt;a href="https://pymorphy2.readthedocs.io/en/stable/"&gt;Pymorphy&lt;/a&gt;, представляющую собой морфологический анализатор.&lt;br /&gt;
Помимо приведения к словарной форме нам потребовалось убрать из текстов часто встречающиеся слова, которые не несут ценности для анализа. Это было необходимо, потому что так называемые стоп-слова могут повлиять на работу используемой модели машинного обучения. Список таких слов мы взяли из пакета &lt;a href="https://www.nltk.org/api/nltk.corpus.html"&gt;ntlk.corpus&lt;/a&gt;, а после расширили его, изучив тексты интервью. Конечно, мы также убрали все знаки пунктуации.&lt;/p&gt;
&lt;h2&gt;Анализ словарного запаса&lt;/h2&gt;
&lt;p&gt;После обработки текста мы посчитали для каждого интервью количество всех слов, а также абсолютное и относительное количество уникальных слов. Конечно, полученные значения неидеальны, так как, во-первых, для большинства интервью были получены автоматически сгенерированные субтитры, которые являются неточными, а во-вторых, тексты были очищены от лишней информации.&lt;br /&gt;
Сперва мы решили наглядно представить основной массив лексики, которая звучит в интервью. После группировки интервью по роду деятельности гостя нам удалось это сделать и в этом нам помогла библиотека &lt;a href="http://amueller.github.io/word_cloud/"&gt;wordcloud&lt;/a&gt;. У нас получились такие облака слов:&lt;/p&gt;
&lt;p&gt;&lt;img src="http://test.leftjoin.ru/pictures/All-wordclouds.png.jpg" border="0" width="100%" height="100%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Лейтмотивом всех интервью Юрия являются обсуждение России (политики, социальной жизни и других особенностей), уровня заработка гостей, а также непосредственно профессиональной деятельности гостя (это особенно заметно у представителей индустрии кинопроизводства).&lt;br /&gt;
Далее мы решили построить боксплот для количества слов для каждого рода деятельности (профессии, которые были представлены единственным гостем, мы не стали учитывать):&lt;/p&gt;
&lt;iframe width="730" height="480" frameborder="0" scrolling="no" src="//plotly.com/~satiukov.e/33.embed?showlink=false"&gt;&lt;/iframe&gt;
&lt;p&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Наиболее разговорчивыми гостями оказались блогеры. По медиане, они наговорили больше всего слов. Чуть поодаль от них журналисты и комики, а вот самыми немногословными оказались рэперы.&lt;/p&gt;
&lt;iframe width="730" height="480" frameborder="0" scrolling="no" src="//plotly.com/~satiukov.e/35.embed?showlink=false"&gt;&lt;/iframe&gt;
&lt;p&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Что касается количества уникальных слов, то тут ситуация аналогичная. И рэперы опять в аутсайдерах…&lt;/p&gt;
&lt;iframe width="730" height="480" frameborder="0" scrolling="no" src="//plotly.com/~satiukov.e/37.embed?showlink=false"&gt;&lt;/iframe&gt;
&lt;p&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Если говорить об отношении уникальных слов к общему количеству, то тут можно увидеть совершенно иную картину. Теперь впереди оказываются, рэперы, музыканты и бизнесмены. Предыдущие же лидеры, наоборот, становятся самыми последними.&lt;br /&gt;
Конечно, стоит отметить, что такие сравнения могут быть несправедливыми, так как длительность интервью у каждого гостя Дудя разная, а потому кто-то просто мог успеть наговорить больше слов, чем остальные. Наглядно в этом можно убедиться, взглянув на распределение длительности интервью по роду деятельности (для построения использовался тот же пул гостей, что и для боксплотов выше):&lt;/p&gt;
&lt;iframe width="730" height="480" frameborder="0" scrolling="no" src="//plotly.com/~satiukov.e/39.embed?showlink=false"&gt;&lt;/iframe&gt;
&lt;p&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;К тому же, разные роды деятельности представляет разное количество человек, это тоже могло сказаться на результатах.&lt;br /&gt;
Далее мы составили список слов, появление которых в интервью было бы интересно отследить, и посмотрели как часто они упоминаются для каждого рода деятельности. Также мы решили учесть дисбаланс среди представителей разных профессиональных категорий и разделили полученные частоты на соответствующее количество гостей.&lt;/p&gt;
&lt;p&gt;&lt;img src="http://test.leftjoin.ru/pictures/-.png" border="0" width="90%" height="90%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Первое место по упоминаниям очевидно занимает Россия. Что касается Запада, то про США гости говорили в 2,5 раза меньше. Что касается лидера РФ, то про него речь заходила достаточно часто. Его оппонент, Алексей Навальный, в этой словесной “баталии” потерпел поражение. Интересно, что политики далеко не в топе по упоминаниям Путина. Впереди оказался экономист Сергей Гуриев, после него ведущий Александр Гордон, а тройку замкнули журналисты.&lt;br /&gt;
Глагол “любить” чаще использовали люди, имеющие отношение к искусству, творчеству и гуманитарным наукам — кинокритик Антон Долин, мультипликатор Олег Куваев, историк Тамара Эйдельман, актеры, рэперы, художник Федор Павлов-Андреевич, комики, музыканты, режиссеры. Про страхи (если судить по глаголу “бояться”) гости говорили реже, чем о любви. В топ вошли историк Эйдельман, дизайнер Артемий Лебедев, кинокритик Долин и политики. Может быть в этом кроется ответ на вопрос, почему же политики не так охотно произносили имя президента России.&lt;br /&gt;
Что касается денег, то о них говорили все. Ну, за исключением человека науки, астрофизика Константина Батыгина. С церковью же имеем совершенно обратную ситуацию. О ней по большей части говорили только писатели и художник Павлов-Андреевич.&lt;/p&gt;
&lt;h2&gt;Анализ мата&lt;/h2&gt;
&lt;p&gt;Далее мы решили проанализировать то, как часто гости Юрия Дудя ругались матом. С помощью регулярных выражений мы составили словарь матерных слов со всех интервью. После этого, для каждого ролика было подсчитано суммарное количество вхождений элементов составленного словаря.&lt;br /&gt;
Мы построили диаграммы, отражающие топ-10 любителей нецензурно выражаться по количеству “запрещенных” слов в минуту.&lt;/p&gt;
&lt;iframe width="730" height="480" frameborder="0" scrolling="no" src="//plotly.com/~satiukov.e/43.embed?showlink=false"&gt;&lt;/iframe&gt;
&lt;p&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Как видим, рэперы и музыканты почти полностью захватили топ. Помимо них очень часто ругались такие гости как блогер Данила Поперечный и комики Иван Усович и Алексей Щербаков. Первое место в рейтинге с большим отрывом от остальных держит Morgenstern (признан иностранным агентом на территории РФ), а вот Олег Тиньков в своем последнем интервью матерился не так много, чтобы попасть в Топ-10.&lt;br /&gt;
Зато, как искрометно!&lt;/p&gt;
&lt;p&gt;&lt;img src="http://test.leftjoin.ru/pictures/FSgosl8XwAAmtpw.jpg" border="0" width="100%" height="100%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;После персонального анализа мы решили узнать, насколько насыщена нецензурными словами речь представителей разных профессиональных групп. Нулевые показатели при этом были опущены.&lt;/p&gt;
&lt;iframe width="730" height="480" frameborder="0" scrolling="no" src="//plotly.com/~satiukov.e/41.embed?showlink=false"&gt;&lt;/iframe&gt;
&lt;p&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Ожидаемо, что больше всех матерились рэперы. На втором месте оказались блогеры (по большей части за счет Поперечного). За ними следует Артемий Лебедев, единственный дизайнер в нашей выборке, благодаря разнообразия речи которого, представители этой профессии и попали в топ-3 этого распределения. Кстати, если вы еще не знакомы с нашим &lt;a href="https://habr.com/ru/post/596035/"&gt;анализом телеграм-канала Лебедева&lt;/a&gt;, то мы не понимаем, чего же вы ждете! Несмотря на то что генератор постов Артемия Лебедева сейчас выключен, исследование его телеграм-канала все равно заслуживает вашего внимания.&lt;/p&gt;
&lt;h2&gt;Ограничения анализа&lt;/h2&gt;
&lt;p&gt;Стоит отметить, что в нашем небольшом исследовании есть два недостатка:&lt;/p&gt;
&lt;ol start="1"&gt;
&lt;li&gt;Как уже говорилось ранее, мы не смогли отделить слова гостей Дудя от речи Юрия, который и сам зачастую не брезгует использовать нецензурные выражения. Однако, задача интервьюера — подстроиться под стиль речи гостя, поэтому, скорее всего, результаты бы не сильно изменились.&lt;/li&gt;
&lt;li&gt;В автосгенерированных субтитрах нам встретилось некое подобие цензуры — некоторые слова были заменены на ‘[ __ ]’. Тут можно выделить несколько интересных моментов:
&lt;ul&gt;
  &lt;li&gt;действительно некоторые матерные слова были зацензурены (по большей части слово “бл**ь”);&lt;/li&gt;
  &lt;li&gt;остальные матерные слова остались нетронутыми;&lt;/li&gt;
  &lt;li&gt;под чистку попали некоторые другие грубые слова, при этом не являющиеся матерными (“мудак”, “гавно”).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Продемонстрируем наглядно на примере следующего диалога:&lt;br /&gt;
&lt;i&gt;Дудь: Почему твои треки такое гавно?&lt;/i&gt;&lt;br /&gt;
&lt;i&gt;Гнойный: Мои треки ох**тельные, Юра, просто ты любишь гавно.&lt;/i&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="http://test.leftjoin.ru/pictures/image.png" border="0" width="100%" height="100%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Такие замены встречались в субтитрах роликов с людьми, которые не употребляли нецензурные выражения в своей речи (по крайней мере на протяжении интервью). Однозначное решение, что же делать с ‘[ __ ]’, мы не смогли принять, поэтому для некоторых гостей какая-то часть матерных слов была, увы, не подсчитана.&lt;/p&gt;
&lt;h2&gt;Работа с Word2vec&lt;/h2&gt;
&lt;p&gt;После статистического анализа интервью мы перешли к определению их контекста. Для этого мы, как и раньше, воспользовались моделью Word2vec. Она основана на нейронной сети и позволяет представлять слова в виде векторов с учетом семантической составляющей. Косинусная мера семантически схожих слов будет стремиться к 1, а у двух слов, не имеющих ничего общего по смыслу, она близка к 0. Модель можно обучать самостоятельно на подготовленном корпусе текстов, но мы решили взять готовую — от &lt;a href="https://rusvectores.org/ru/"&gt;RusVectores&lt;/a&gt;.  Для ее использования нам понадобилась библиотека &lt;a href="https://radimrehurek.com/gensim/"&gt;gensim&lt;/a&gt;.&lt;br /&gt;
Мы рассчитали векторы-представления для каждой профессиональной группы. Наверное, можно ожидать, что режиссёры обсуждали кино и все, что с ним связано, а музыканты — музыку. Поэтому для каждого рода деятельности мы получили список слов, описывающих тематику текстов соответствующих роликов. Также мы раскрасили ячейки в зависимости от того, насколько каждое полученное слово было близко к текстам соответствующей категории гостей.&lt;/p&gt;
&lt;p&gt;&lt;img src="http://test.leftjoin.ru/pictures/-10-----.png" border="0" width="120%" height="120%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Можно сказать, что в целом каждая профессиональная категория описывается вполне соответствующими терминами. Конечно, некоторые слова могут показаться спорными. К примеру, на первом месте для рэперов стоит слово “джазовый”, хотя ни с 1 представителем хип-хоп течения речь о джазе не заходила. Тем не менее модель посчитала, что это слово достаточно близко к общему смыслу интервью людей, относящихся к этой категории (видимо, за счет непосредственного отношения рэперов к музыке).&lt;/p&gt;
&lt;h2&gt;P.S. Мистическое число 25.000000&lt;/h2&gt;
&lt;p&gt;Как мы уже говорили, среди скачанных субтитров некоторые были написаны вручную. Интересно, что все они начинаются с числа 25.000000, причем оно нигде не озвучивается.&lt;/p&gt;
&lt;p&gt;&lt;img src="http://test.leftjoin.ru/pictures/25.000000--1.png" border="0" width="50%" height="100%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Что же это за мистическое число? Если уйти в конспирологию, то можно вспомнить про 25-й кадр. К сожалению, нам об этом ничего неизвестно, мы просто оставим это как пищу для размышлений…&lt;/p&gt;
&lt;p&gt;&lt;img src="http://test.leftjoin.ru/pictures/25.000000-.png" border="0" width="100%" height="100%"&gt;&lt;/a&gt;&lt;/p&gt;
</description>
<pubDate>Thu, 02 Jun 2022 11:09:33 +0300</pubDate>
</item>

<item>
<title>Десять советов, чтобы писать SQL-код, который приятно читать и использовать</title>
<guid isPermaLink="false">130</guid>
<link>http://test.leftjoin.ru/</link>
<comments>http://test.leftjoin.ru/</comments>
<description>
&lt;p&gt;&lt;img src="http://test.leftjoin.ru/pictures/1*NGniUKoit2YaJY08brT01g.png" border="0" width="100%" height="100%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p class="note"&gt;Перевод статьи &lt;a href="https://towardsdatascience.com/10-best-practices-to-write-readable-and-maintainable-sql-code-427f6bb98208"&gt;”10 Best Practices to Write Readable and Maintainable SQL Code”&lt;/a&gt; автора &lt;a href="https://davidjmartins.medium.com/?source=post_page-----427f6bb98208-----------------------------------"&gt;David Martins&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Как писать SQL-запросы, которые ваша команда сможет легко читать и использовать?&lt;/h2&gt;
&lt;p&gt;Без хорошей культуры написания SQL-запросы очень легко становятся запутанными. У каждого члена команды могут быть свои привычки написания запросов на SQL и вы очень быстро можете получить запутанный код, который будет понятен лишь одному человеку, а все остальные не смогут его использовать.&lt;/p&gt;
&lt;p&gt;Думаю, вы осознаёте важность наличия общих правил по написанию SQL-запросов в команде. Эта статья может стать отличным руководством для формирования таких правил!&lt;/p&gt;
&lt;h3&gt;&lt;b&gt;1. Используйте верхний регистр для ключевых слов&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;Начнем с базовых вещей: используйте заглавные буквы для ключевых слов SQL и строчные буквы для обозначения таблиц и столбцов. Также рекомендуется использовать прописные буквы для функций SQL (FIRST_VALUE(), DATE_TRUNC() и т. д.), хотя это спорно.&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Избегать:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;select id, name from company.customers&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Предпочтительно:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT id, name FROM company.customers&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;&lt;b&gt;2. Используйте Snake Case для схем, таблиц, столбцов&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;В разных языках программирования есть разные оптимальные стили написания названий из нескольких слов: camelCase, PascalCase, kebab-case, and snake_case являются наиболее распространенными.&lt;/p&gt;
&lt;p&gt;Если говорить про SQL, Snake Case (иногда называемый регистром подчеркивания) является наиболее широко используемым правилом. Чтобы писать в стиле snake_case, нужно заменить пробелы знаками подчеркивания. При этом все слова пишутся строчными буквами.&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Избегать:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT Customers.id, 
       Customers.name, 
       COUNT(WebVisit.id) as nbVisit
FROM COMPANY.Customers
JOIN COMPANY.WebVisit ON Customers.id = WebVisit.customerId

WHERE Customers.age &amp;lt;= 30
GROUP BY Customers.id, Customers.name&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Предпочтительно:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT customers.id, 
       customers.name, 
       COUNT(web_visit.id) as nb_visit
FROM company.customers
JOIN company.web_visit ON customers.id = web_visit.customer_id

WHERE customers.age &amp;lt;= 30
GROUP BY customers.id, customers.name&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Хотя некоторым нравится использовать разные стили составных названий (чтобы различать схемы, таблицы и столбцы), я бы рекомендовал придерживаться Snake Case.&lt;/p&gt;
&lt;h3&gt;&lt;b&gt;3. Используйте псевдонимы, чтобы сделать запрос понятнее&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;Хорошо известно, что псевдонимы — это удобный способ для переименования таблиц или столбцов, чтобы добавить точность смысловой нагрузкой. Не стесняйтесь давать псевдонимы таблицам и столбцам, чтобы название лучше описывало процесс в а функциях.&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Избегать:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT customers.id, 
       customers.name, 
       customers.context_col1,
       nested.f0_
FROM company.customers
JOIN (
          SELECT customer_id,
                 MIN(date)
          FROM company.purchases
          GROUP BY customer_id
      ) ON customer_id = customers.id&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Предпочтительно:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT customers.id, 
       customers.name, 
       customers.context_col1 as ip_address,
       first_purchase.date    as first_purchase_date
FROM company.customers
JOIN (
          SELECT customer_id,
                 MIN(date) as date
          FROM company.purchases
          GROUP BY customer_id
      ) AS first_purchase 
        ON first_purchase.customer_id = customers.id&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Я обычно использую для столбцов строчные буквы “as”, а для таблиц — прописные “AS”.&lt;/p&gt;
&lt;h3&gt;&lt;b&gt;4. Форматирование: осторожно используйте отступы и пробелы&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;Это базовый принцип. Один из ключевых элементов, чтобы все функции в вашем запросе были четко видны. Если вы знаете Python (в нем без грамотных отступов код не будет работать), то примените этот навык здесь.&lt;/p&gt;
&lt;p&gt;Используйте пробелы после ключевого слова и при обозначении подзапроса или производной таблицы.&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Избегать:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT customers.id, customers.name, customers.age, customers.gender, customers.salary, first_purchase.date
FROM company.customers
LEFT JOIN ( SELECT customer_id, MIN(date) as date FROM company.purchases GROUP BY customer_id ) AS first_purchase 
ON first_purchase.customer_id = customers.id 
WHERE customers.age&amp;lt;=30&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Предпочтительно:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT customers.id, 
       customers.name, 
       customers.age, 
       customers.gender, 
       customers.salary,
       first_purchase.date
FROM company.customers
LEFT JOIN (
              SELECT customer_id,
                     MIN(date) as date 
              FROM company.purchases
              GROUP BY customer_id
          ) AS first_purchase 
            ON first_purchase.customer_id = customers.id
WHERE customers.age &amp;lt;= 30&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Обратите внимание, как использованы пробелы в условии “where”.&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Избегать:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT id WHERE customers.age&amp;lt;=30&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Предпочтительно:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT id WHERE customers.age &amp;lt;= 30&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;&lt;b&gt;5. Избегайте Select&lt;/b&gt;*&lt;/h3&gt;
&lt;p&gt;Не забывайте об этом правиле: вам следует точно указывать, какие элементы таблицы вы хотите выбрать и забыть про Select*!&lt;/p&gt;
&lt;p&gt;Select* делает ваш запрос неясным, поскольку он скрывает намерения, стоящие за запросом. Кроме того, помните, что ваши таблицы могут эволюционировать и влиять на Select*. Вот почему я не большой поклонник инструкции EXCEPT().&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Избегать:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT * EXCEPT(id) FROM company.customers&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Предпочтительно:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT name,
       age,
       salary
FROM company.customers&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;&lt;b&gt;6. Используйте синтаксис JOIN ANSI-92&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;…для соединения таблиц, вместо условия WHERE. Несмотря на то, что для соединения таблиц можно использовать как условие WHERE, так и условие JOIN, лучше использовать синтаксис JOIN/ANSI-92.&lt;/p&gt;
&lt;p&gt;Хотя с точки зрения производительности разницы нет, условие JOIN отделяет логику отношения от фильтров.&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Избегать:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT customers.id, 
       customers.name, 
       COUNT(transactions.id) as nb_transaction
FROM company.customers, company.transactions
WHERE customers.id = transactions.customer_id
      AND customers.age &amp;lt;= 30
GROUP BY customers.id, customers.name&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Предпочтительно:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT customers.id, 
       customers.name, 
       COUNT(transactions.id) as nb_transaction
FROM company.customers
JOIN company.transactions ON customers.id = transactions.customer_id
WHERE customers.age &amp;lt;= 30
GROUP BY customers.id, customers.name&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Синтаксис, основанный на условии “Where”, также известный как ANSI-89, старше нового ANSI-92, поэтому он всё ещё очень распространен. Сегодня большинство разработчиков и аналитиков данных используют синтаксис JOIN.&lt;/p&gt;
&lt;h3&gt;&lt;b&gt;7. Используйте обобщённое табличное выражение (Common Table Expression — CTE)&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;CTE позволяет создать запрос, результат которого существует временно и может использоваться в более крупном запросе. CTE доступны в большинстве современных баз данных.&lt;/p&gt;
&lt;p&gt;Он работает как производная таблица с двумя преимуществами:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Использование CTE улучшает читабельность вашего запроса.&lt;/li&gt;
&lt;li&gt;CTE определяется один раз, после чего на него можно ссылаться многократно.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CTE объявляется с помощью инструкции &lt;b&gt;WITH … AS&lt;/b&gt;:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;WITH my_cte AS
(
  SELECT col1, col2 FROM table
)
SELECT * FROM my_cte&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Избегать:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SELECT customers.id, 
       customers.name, 
       customers.age, 
       customers.gender, 
       customers.salary,
       persona_salary.avg_salary as persona_avg_salary,
       first_purchase.date
FROM company.customers
JOIN (
          SELECT customer_id,
                 MIN(date) as date 
          FROM company.purchases
          GROUP BY customer_id
      ) AS first_purchase 
        ON first_purchase.customer_id = customers.id
JOIN (
          SELECT age,
             gender,
             AVG(salary) as avg_salary
         FROM company.customers
         GROUP BY age, gender
      ) AS persona_salary 
        ON persona_salary.age = customers.age
           AND persona_salary.gender = customers.gender
WHERE customers.age &amp;lt;= 30&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Предпочтительно:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;WITH first_purchase AS
(
   SELECT customer_id,
          MIN(date) as date 
   FROM company.purchases
   GROUP BY customer_id
),
persona_salary AS
(
   SELECT age,
          gender,
          AVG(salary) as avg_salary
   FROM company.customers
   GROUP BY age, gender
)
SELECT customers.id, 
       customers.name, 
       customers.age, 
       customers.gender, 
       customers.salary,
       persona_salary.avg_salary as persona_avg_salary,
       first_purchase.date
FROM company.customers
JOIN first_purchase ON first_purchase.customer_id = customers.id
JOIN persona_salary ON persona_salary.age = customers.age
                       AND persona_salary.gender = customers.gender
WHERE customers.age &amp;lt;= 30&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;&lt;b&gt;8. Иногда стоит разделить запрос на несколько&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;...но не увлекайтесь. Давайте разберем на примере.&lt;/p&gt;
&lt;p&gt;Я часто использую AirFlow для выполнения SQL-запросов в BigQuery, преобразования данных и подготовки визуализации данных. В нем есть оркестратор рабочих процессов (Airflow), который выполняет запросы в определенном порядке. В некоторых ситуациях лучше разбивать сложные запросы на несколько более мелких.&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Вместо:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;CREATE TABLE customers_infos AS
SELECT customers.id,
       customers.salary,
       traffic_info.weeks_since_last_visit,
       category_info.most_visited_category_id,
       purchase_info.highest_purchase_value
FROM company.customers
LEFT JOIN ([..]) AS traffic_info
LEFT JOIN ([..]) AS category_info
LEFT JOIN ([..]) AS purchase_info&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Вы могли бы использовать:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;## STEP1: Create initial table
CREATE TABLE public.customers_infos AS
SELECT customers.id,
       customers.salary,
       0 as weeks_since_last_visit,
       0 as most_visited_category_id,
       0 as highest_purchase_value
FROM company.customers
## STEP2: Update traffic infos
UPDATE public.customers_infos
SET weeks_since_last_visit = DATE_DIFF(CURRENT_DATE,
                                       last_visit.date, WEEK)
FROM (
         SELECT customer_id, max(visit_date) as date
         FROM web.traffic_info
         GROUP BY customer_id
     ) AS last_visit
WHERE last_visit.customer_id = customers_infos.id
## STEP3: Update category infos
UPDATE public.customers_infos
SET most_visited_category_id = [...]
WHERE [...]
## STEP4: Update purchase infos
UPDATE public.customers_infos
SET highest_purchase_value = [...]
WHERE [...]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;ПРЕДУПРЕЖДЕНИЕ!&lt;/b&gt; &lt;br /&gt;
Несмотря на то, что этот метод отлично подходит для упрощения сложных запросов, вместе с повышением читабельности кода, вы можете здорово понизить его производительность.&lt;/p&gt;
&lt;p&gt;Это особенно важно, если вы работаете с базой данных OLAP или любой колоночной базой данных, оптимизированной для агрегационных и аналитических запросов (SELECT, AVG, MIN, MAX, …), но менее производительной, когда речь идет о транзакциях (UPDATE).&lt;/p&gt;
&lt;p&gt;Несмотря на это в некоторых случаях это может улучшить производительность работы с базой данных. Даже в современной базе данных, ориентированной на колонки, слишком большое количество JOIN’ов приведет к проблемам с памятью или производительностью. В таких ситуациях разделение вашего запроса обычно помогает улучшить производительность и оптимизировать используемую память.&lt;/p&gt;
&lt;p&gt;Кроме того, не стоит забывать, что вам нужен инструмент или оркестратор для выполнения ваших запросов в определенном порядке.&lt;/p&gt;
&lt;h3&gt;&lt;b&gt;9. Осмысленные названия, основанные на ваших внутренних правилах&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;Правильно называть схемы и таблицы сложно. Какие варианты возможных имён использовать — вопрос дискуссионный, но задача выбора единых правил присвоения имён — это не сложно. Вы должны определить &lt;b&gt;свои&lt;/b&gt; правила и использовать их всей командой.&lt;/p&gt;
&lt;p&gt;“В компьютерных науках есть только две сложные проблемы: аннулирование кэша и придумывание названий.” — Фил Карлтон&lt;/p&gt;
&lt;p&gt;Вот примеры правил, которые я использую:&lt;/p&gt;
&lt;h4&gt;&lt;b&gt;Схемы&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;Если вы работаете с аналитической базой данных, которая служит нескольким целям, хорошей практикой является организация таблиц в выразительные схемы.&lt;/p&gt;
&lt;p&gt;В нашей базе данных BigQuery у нас есть одна схема для каждого источника данных. Что еще более важно, мы выводим результаты в разных схемах в зависимости от их назначения.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Любая таблица, которая будет доступна для стороннего инструмента, находится в *&lt;b&gt;общедоступной&lt;/b&gt;* схеме. Инструменты визуализации данных, такие как DataStudio или Tableau, получают данные из неё.&lt;/li&gt;
&lt;li&gt;Поскольку мы используем машинное обучение с BQML, ****&lt;b&gt;у нас есть специальная схема &lt;/b&gt;*machine_learning.***&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Таблицы&lt;/h4&gt;
&lt;p&gt;Сами таблицы должны называться в соответствии с правилами. В Agorapulse у нас есть несколько дашбордов для визуализации данных, каждый из которых имеет свое назначение: дашборд управления маркетингом, дашборд управления продуктом, дашборд управления для руководителей и многие другие.&lt;/p&gt;
&lt;p&gt;Каждая таблица в нашей общедоступной схеме имеет префикс имени дашборда. Выглядит это примерно так:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;product_inbox_usage
product_addon_competitor_stats
marketing_acquisition_agencies
Executive_funnel_overview&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;При командной работе стоит уделить время определению общих правил. Когда вы придумываете название новой таблицы, то не используйте быстрое и заезженное имя, которое вы «измените позже» — вы наверняка этого не сделаете.&lt;/p&gt;
&lt;p&gt;Не стесняйтесь использовать эти примеры для создания своих правил.&lt;/p&gt;
&lt;h3&gt;&lt;b&gt;10. Пишите полезные комментарии… но не слишком много&lt;/b&gt;&lt;/h3&gt;
&lt;p class="note"&gt;&lt;i&gt;Примечание переводчика:&lt;/i&gt; Автор пишет, что “хорошо написанный код с правильными названиями не нуждается в комментариях” и иронично продемонстрировал детально расписанный код вообще без комментариев. Мы все-таки за то, чтобы необходимые пояснения в коде были, ведь это сильно ускоряет понимание сути запроса.&lt;/p&gt;
&lt;p&gt;Я согласен с тезисом, что хорошо написанный код с правильными названиями не нуждается в комментариях. Тот, кто читает ваш код, должен понимать логику и замысел еще до того, как появится результат работы кода.&lt;/p&gt;
&lt;p&gt;Тем не менее, комментарии могут быть полезны в некоторых ситуациях. Но перебарщивать с ними не стоит!&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Избегать:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;WITH fp AS
(
   SELECT c_id,               # customer id
          MIN(date) as dt     # date of first purchase
   FROM company.purchases
   GROUP BY c_id
),
ps AS
(
   SELECT age,
          gender,
          AVG(salary) as avg
   FROM company.customers
   GROUP BY age, gender
)
SELECT customers.id, 
       ct.name, 
       ct.c_age,            # customer age
       ct.gender,
       ct.salary,
       ps.avg,              # average salary of a similar persona
       fp.dt                # date of first purchase for this client
FROM company.customers ct
# join the first purchase on client id
JOIN fp ON c_id = ct.id
# match persona based on same age and genre
JOIN ps ON ps.age = c_age
           AND ps.gender = ct.gender
WHERE c_age &amp;lt;= 30&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Предпочтительно:&lt;/b&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;WITH first_purchase AS
(
   SELECT customer_id,
          MIN(date) as date 
   FROM company.purchases
   GROUP BY customer_id
),
persona_salary AS
(
   SELECT age,
          gender,
          AVG(salary) as avg_salary
   FROM company.customers
   GROUP BY age, gender
)
SELECT customers.id, 
       customers.name, 
       customers.age, 
       customers.gender, 
       customers.salary,
       persona_salary.avg_salary as persona_avg_salary,
       first_purchase.date
FROM company.customers
JOIN first_purchase ON first_purchase.customer_id = customers.id
JOIN persona_salary ON persona_salary.age = customers.age
                       AND persona_salary.gender = customers.gender
WHERE customers.age &amp;lt;= 30&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;&lt;b&gt;Вывод&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;SQL великолепен. Это одна из основ анализа данных, науки о данных, инжиниринга данных и даже разработки программного обеспечения: неправильный код не простит ошибки. Его гибкость является силой, но может быть ловушкой.&lt;/p&gt;
&lt;p&gt;Сначала вы можете этого не осознавать, особенно если с кодом работаете только вы. Но когда вы работаете в команде или если кто-то должен будет продолжать вашу работу, SQL-код написанный без соблюдения этих правил будет ночным кошмаром аналитика.&lt;/p&gt;
&lt;p&gt;В этой статье я обобщил наиболее распространенные рекомендации по написанию SQL-запросов. Конечно, некоторые из них дискуссионные или основаны на личном мнении — вы можете черпать отсюда вдохновение и создавать собственные правила со своей командой.&lt;/p&gt;
&lt;p&gt;Я надеюсь, что эта информация поможет вам вывести качество SQL на новый уровень!&lt;/p&gt;
</description>
<pubDate>Tue, 22 Feb 2022 16:24:18 +0300</pubDate>
</item>

<item>
<title>Граф телеграм-каналов по теме аналитики</title>
<guid isPermaLink="false">120</guid>
<link>http://test.leftjoin.ru/all/analytical-telegram-channels-graph/</link>
<comments>http://test.leftjoin.ru/all/analytical-telegram-channels-graph/</comments>
<description>
&lt;p&gt;&lt;a href="http://test.leftjoin.ru/files/analytics-graph.html" style="text-decoration:none; border:0"&gt;&lt;img src="http://test.leftjoin.ru/pictures/graph.png" border="0" width="100%" height="150%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Авторы самых разных блогов в телеграме часто публикуют подборки любимых каналов, которыми они хотят поделиться со своей аудиторией. Идея, конечно, не новая, но я решил не просто составить рейтинг интересных аналитических телеграм-блогов, а решить эту задачу аналитически.&lt;/p&gt;
&lt;p&gt;В рамках текущего курса моей учебы, я изучаю много современных подходов к анализу и визуализации данных. В самом начале курса было разминочное упражнение: объектно-ориентированное программирование на Python для сбора и итеративного построения графа с &lt;a href="https://www.themoviedb.org/documentation/api"&gt;TMDB API&lt;/a&gt;. В задаче этот метод применяется для построения графа связи актеров, где связь — игра в одном и том же фильме. Но я решил, что можно применить его и к другой задаче: построению графа связей аналитического сообщества.&lt;/p&gt;
&lt;p&gt;Поскольку последнее время мой временной ресурс особенно ограничен, а аналогичную задачу для курса я уже выполнил, то я решил передать эти знания кому-то еще, кто интересуется аналитикой. К счастью, в этот момент, ко мне в личку постучался кандидат на вакансию младшего аналитика данных — Андрей. Он сейчас находится в процессе постижения всех тонкостей аналитики, поэтому мы договорились на стажировку, в рамках которой Андрей спарсил данные с telegram-каналов.&lt;/p&gt;
&lt;p&gt;Основной задачей Андрея был сбор всех текстов с телеграм-канала Интернет-аналитика, выделение каналов, на которые ссылался Алексей Никушин, сбор текстов из этих телеграм-каналов и ссылок на этих каналах. Под “ссылкой” подразумевается любое упоминание канала: через @, через ссылку или репостом. В результате парсинга, у Андрея получилось два файла: nodes и edges.&lt;br /&gt;
Теперь я представлю вам &lt;a href="http://test.leftjoin.ru/files/analytics-graph.html"&gt;граф, который получился у меня на основе этих данных&lt;/a&gt; и прокомментирую результаты.&lt;/p&gt;
&lt;p&gt;Пользуясь случаем, хочу выразить мое почтение команде karpov.courses, поскольку у Андрея отличное знание языка Python!&lt;/p&gt;
&lt;p&gt;В результате топ-10 каналов по показателю degree (количество связей) выглядит так:&lt;/p&gt;
&lt;ol start="1"&gt;
&lt;li&gt;&lt;a href="https://t.me/internetanalytics"&gt;Интернет-аналитика&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/revealthedata"&gt;Reveal The Data&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/rockyourdata"&gt;Инжиниринг Данных&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/data_events"&gt;Data Events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/datalytx"&gt;Datalytics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/chartomojka"&gt;Чартомойка&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/leftjoin"&gt;LEFT JOIN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/epicgrowth_chat"&gt;Epic Growth&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/rtdlinks"&gt;RTD: ссылки и репосты&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/dashboardets"&gt;Дашбордец&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;По-моему, получилось супер-круто и визуально интересно, а Андрей — большой молодец! Кстати, он тоже начал свой канал &lt;a href="https://t.me/eto_analytica"&gt;”Это разве аналитика?”&lt;/a&gt;, где публикуются новости аналитики.&lt;/p&gt;
&lt;p&gt;Забегая вперед: у этой задачи имеется продолжение. С помощью Марковской цепи мы смоделировали в каком канале окажется пользователь, если будет переходить итеративно по всем упоминаниям в каналах. Получилось очень интересно, но об этом мы расскажем в следующий раз!&lt;/p&gt;
</description>
<pubDate>Mon, 27 Sep 2021 17:47:31 +0300</pubDate>
</item>

<item>
<title>Принципы построения bubble-charts: площадь VS радиус</title>
<guid isPermaLink="false">119</guid>
<link>http://test.leftjoin.ru/all/principy-postroeniya-bubble-charts-ploschad-vs-radius/</link>
<comments>http://test.leftjoin.ru/all/principy-postroeniya-bubble-charts-ploschad-vs-radius/</comments>
<description>
&lt;p&gt;Такой навык как визуализация данных применяется в любой отрасли, где присутствуют данные, ведь таблицы хороши лишь для хранения информации. Когда есть необходимость презентовать данные, точнее определенные выводы, полученные на их основе — данные необходимо представить на графиках подходящего типа. И тут перед вами встает две задачи: первая — правильно подобрать тип графика, вторая — правдоподобно отразить результаты на диаграмме. Сегодня мы расскажем вам об одной ошибке, которую иногда допускают дизайнеры при визуализации данных на bubble-charts и о том, как эту ошибку можно избежать.&lt;/p&gt;
&lt;h2&gt;Суть построения bubble-чарта&lt;/h2&gt;
&lt;p&gt;Немного скучной теории перед тем, как мы приступим к анализу данных. Bubble-chart — удобный способ показать три параметра наблюдения без построения трехмерной модели. По привычным осям X и Y указываются значения двух параметров, а третий показан размером круга, который соответствует каждому наблюдению. Именно это позволяет избежать необходимости построения сложного 3D графика, то есть любой, кто видит bubble-chart, гораздо быстрее сможет сделать выводы о данных изображенных на одной плоскости.&lt;/p&gt;
&lt;h2&gt;Ошибка, которую может допустить дизайнер, но не аналитик данных&lt;/h2&gt;
&lt;p&gt;С метриками, которые отображены на осях графика не возникает никаких вопросов, это привычный способ их визуализации, а вот с размерами возникает некоторая трудность: как грамотно и точно отобразить изменения в значениях переменной, если управление идет не точкой на оси, а размером этой точки?&lt;br /&gt;
Дело в том, что при построении такого графика без использования аналитических средств, например, в графическом редакторе, автор может нарисовать круги, принимая радиус круга за его размер. На первый взгляд, все кажется абсолютно корректным — чем больше значение переменной, тем больше радиус круга. Однако, в таком случае, площадь круга будет увеличиваться не как линейная, а как степенная функция, ведь S = π × r2. Например, на рисунке ниже показано, что, если увеличить радиус круга в два раза, то площадь увеличится в 4 раза.&lt;/p&gt;
&lt;p&gt;&lt;details&gt;&lt;br /&gt;
&lt;summary&gt;&lt;span style="color:#7ea9b8"&gt;Построение круга в Matplotlib&lt;/span&gt;&lt;/summary&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;fig = plt.figure(figsize=(10, 10))
ax = fig.add_subplot(1, 1, 1)
s = 4*10e3


ax.scatter(100, 100, s=s, c='r')
ax.scatter(100, 100, s=s/4 ,c='b')
ax.scatter(100, 100, s=10, c='g')
plt.axvline(99, c='black')
plt.axvline(101, c='black')
plt.axvline(98, c='black')
plt.axvline(102, c='black')


ax.set_xticks(np.arange(95, 106, 1))
ax.grid(alpha=1)

plt.show()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;/details&gt;&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/example.png" width="720" height="720" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Это значит, что график будет выглядеть неправдоподобно, ведь размеры не будут отражать реальное изменение переменной, а человек обращает внимание и сравнивает именно площадь кругов на графике.&lt;/p&gt;
&lt;h2&gt;Как построить такой график правильно?&lt;/h2&gt;
&lt;p&gt;К счастью, если строить bubble-charts с помощью библиотек Python (Matplotlib и Seaborn), то размер круга будет определяться именно площадью, что абсолютно корректно и грамотно с точки зрения визуализации.&lt;br /&gt;
Сейчас на примере реальных данных, найденных на Kaggle, покажем, как построить bubble-chart правильно. В данных присутствуют следующие переменные: страна, численность населения, процент грамотного населения. Для того чтобы диаграмма была читаемой, возьмем подвыборку из 10 первых стран после сортировки всех данных по возрастанию ВВП.&lt;/p&gt;
&lt;p&gt;Для начала, загрузим все нужные библиотеки:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Затем, загрузим данные, очистим и от всех строк с пропущенными значениями и приведем данные по численности населения стран в миллионы:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;data = pd.read_csv('countries of the world.csv', sep = ',')
data = data.dropna()
data = data.sort_values(by = 'Population', ascending = False)
data = data.head(10)
data['Population'] = data['Population'].apply(lambda x: x/1000000)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь, когда все подготовка завершена, можно построить bubble-chart:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;sns.set(style=&amp;quot;darkgrid&amp;quot;)    
fig, ax = plt.subplots(figsize=(10, 10))    
g = sns.scatterplot(data=data, x=&amp;quot;Literacy (%)&amp;quot;, y=&amp;quot;GDP ($ per capita)&amp;quot;, size = &amp;quot;Population&amp;quot;, sizes=(10,1500), alpha=0.5)
plt.xlabel(&amp;quot;Literacy (Percentage of literate citizens)&amp;quot;)
plt.ylabel(&amp;quot;GDP per Capita&amp;quot;)
plt.title('Chart with bubbles as area', fontdict= {'fontsize': 'x-large'})

def label_point(x, y, val, ax):
    a = pd.concat({'x': x, 'y': y, 'val': val}, axis=1)
    for i, point in a.iterrows():
        ax.text(point['x'], point['y']+500, str(point['val']))

label_point(data['Literacy (%)'], data['GDP ($ per capita)'], data['Country'], plt.gca()) 

ax.legend(loc='upper left', fontsize = 'medium', title = 'Population (in mln)', title_fontsize = 'large', labelspacing = 1)

plt.show()&lt;/code&gt;&lt;/pre&gt;&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/_32.png" width="720" height="720" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;На этом графике получилось понятным образом отобразить три метрики: уровень ВВП на душу населения по оси Y, процент грамотного населения по оси X и численность населения — площадью круга.&lt;/p&gt;
&lt;p&gt;Мы рекомендуем использовать площадь в качестве переменной, которая отвечает за размер фигуры, если есть необходимость показать несколько переменных на одном графике.&lt;/p&gt;
</description>
<pubDate>Wed, 22 Sep 2021 12:57:54 +0300</pubDate>
</item>

<item>
<title>Различия между медианой и средним арифметическим как целевым показателем анализа данных</title>
<guid isPermaLink="false">118</guid>
<link>http://test.leftjoin.ru/all/mean-vs-median/</link>
<comments>http://test.leftjoin.ru/all/mean-vs-median/</comments>
<description>
&lt;p&gt;В сегодняшней статье мы бы хотели осветить простую, но в то же время важную тему выбора простой метрики для оценки того или иного датасета. Со средним арифметическим все давным давно знакомы, чуть ли не каждый школьник отлично знает, что нужно просуммировать все имеющиеся значения, поделить на их количество и получить среднее значение. В школьные знания не входят никакие альтернативные варианты, которых, на самом деле, в статистике много — на любой вкус и случай. Однако, в решении исследовательских и маркетинговых задач люди часто берут именно эту метрику за основу. Правомерно ли это или есть более удачный вариант? Давайте разбираться.&lt;/p&gt;
&lt;p&gt;Для начала стоит вспомнить определения двух метрик, о которых мы сегодня поговорим.&lt;br /&gt;
Среднее  — самый популярный статистический показатель, который используется для измерения центра данных. А что же такое медиана? Медиана — значение, которое разбивает данные, отсортированные по порядку увеличения значений, на две равные части. Это значит, что медиана показывает центральное значение в выборке, если наблюдений нечетное количество и среднее арифметическое двух значений, если количество наблюдений в выборке четно.&lt;/p&gt;
&lt;h2&gt;Исследовательские задачи&lt;/h2&gt;
&lt;p&gt;Итак, оценка среднего значения выборки — зачастую важна во многих исследовательских вопросах. Например, специалисты, изучающие демографию часто задаются вопросом изменения численности регионов России, чтобы проследить за динамикой и отразить это в отчетностях. Давайте попробуем рассчитать среднюю численность региона России, а также медиану, а затем сравним полученные результаты.&lt;br /&gt;
Для начала, нужно найти и загрузить данные, подключив для этого библиотеку pandas.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import pandas as pd
city = pd.read_csv('city.csv')&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Затем, нужно посчитать среднее и медиану выборки.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;mean_pop = round(city.population_2020.mean(), 0)
median_pop = round(city.population_2020.median(), 0)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Значения, естественно, получились разными, так как распределение наблюдений в выборке отлично от нормального. Для того, чтобы понять, сильно ли они отличаются, построим график распределения и отметим среднее и медиану.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import matplotlib.pyplot as plt
import seaborn as sns

sns.set_palette('rainbow')
fig = plt.figure(figsize = (20, 15))
ax = fig.add_subplot(1, 1, 1)
g = sns.histplot(data = city, x= 'population_2020', alpha=0.6, bins = 100, ax=ax)

g.axvline(mean_pop, linewidth=2, color='r', alpha=0.9, linestyle='--', label = 'Среднее = {:,.0f}'.format(mean_pop).replace(',', ' '))
g.axvline(median_pop, linewidth=2, color='darkgreen', alpha=0.9, linestyle='--', label = 'Медиана = {:,.0f}'.format(median_pop).replace(',', ' '))

plt.ticklabel_format(axis='x', style='plain')
plt.xlabel(&amp;quot;Численность населения&amp;quot;, fontsize=25)
plt.ylabel(&amp;quot;Количество городов&amp;quot;, fontsize=25)
plt.title(&amp;quot;Распределение численности населения российских городов&amp;quot;, fontsize=25)
plt.legend(fontsize=&amp;quot;xx-large&amp;quot;)
plt.show()&lt;/code&gt;&lt;/pre&gt;&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/avg_p2.jpg" width="1440" height="1080" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Также, на этих данных стоит построить боксплот для более точной визуализации основных квантилей распределения, медианы, среднего и выбросов.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;fig = plt.figure(figsize = (10, 10))
sns.set_theme(style=&amp;quot;whitegrid&amp;quot;)
sns.set_palette(palette=&amp;quot;pastel&amp;quot;)

sns.boxplot(y = city['population_2020'], showfliers = False)

plt.scatter(0, 550100, marker='*', s=100, color = 'black', label = 'Выбросы')
plt.scatter(0, 560200, marker='*', s=100, color = 'black')
plt.scatter(0, 570300, marker='*', s=100, color = 'black')
plt.scatter(0, mean_pop, marker='o', s=100, color = 'red', edgecolors = 'black', label = 'Среднее')
plt.legend()

plt.ylabel(&amp;quot;Численность населения&amp;quot;, fontsize=15)
plt.ticklabel_format(axis='y', style='plain')
plt.title(&amp;quot;Боксплот численности населения&amp;quot;, fontsize=15)
plt.show()&lt;/code&gt;&lt;/pre&gt;&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/bp_city.jpg" width="720" height="720" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Из графиков следует, что медиана существенно меньше среднего, а также, ясно, что это следствие наличия больших выбросов — Москвы и Санкт-Петербурга. Поскольку среднее арифметическое — метрика крайне чувствительная к выбросам — при их наличии в выборке опираться на выводы относительно среднего не стоит. Рост или снижение численности населения Москвы может сильно смещать среднюю численность по России, однако это не будет влиять на настоящий общерегиональный тренд.&lt;br /&gt;
Используя среднее арифметическое мы скажем, что численность типичного (среднего) города в РФ — 268 тысяч человек. Однако, это вводит нас в заблуждение, так как среднее значительно превышает медиану исключительно из-за численности населения Москвы и Санкт-Петербурга. На самом деле, численность типичного российского города существенно меньше (аж в 2 раза!) и составляет 104 тысячи жителей.&lt;/p&gt;
&lt;h2&gt;Маркетинговые задачи&lt;/h2&gt;
&lt;p&gt;В контексте бизнеса разница между средним арифметическим и медианой также важна, так как использование неверной метрики может серьезно сказаться на результатах проведения акции или затруднить достижение цели. Давайте посмотрим на реальном примере, с какими трудностями может столкнуться предприниматель в ритейле, если неверно выберет целевую метрику.&lt;br /&gt;
Для начала, как и в предыдущем примере, загрузим датасет о покупках в супермаркете. Выберем необходимые для анализа столбцы датасета и переименуем их, для упрощения кода в дальнейшем. Поскольку эти данные не так хорошо подготовлены, как предыдущие, необходимо сгруппировать все купленные товары по чекам. В этом случае необходима группировка по двум переменным: по id покупателя и по дате покупки (дата и время определяется моментом закрытия чека, поэтому все покупки в рамках одного чека совпадают по дате). Затем, назовем полученный столбец «total_bill», то есть сумма чека и посчитаем среднее и медиану.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;df = pd.read_excel('invoice_data.xlsx')
df_nes = df[['Номер КПП', 'Сумма', 'Дата продажи']]
df_nes.columns = ['user','total_price', 'date']
groupped_df = pd.DataFrame(df_nes.groupby(['user', 'date']).total_price.sum())
groupped_df.columns = ['total_bill']
mean_bill = groupped_df.total_bill.mean()
median_bill = groupped_df.total_bill.median()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь, как и в предыдущем примере нужно построить график распределения чеков покупателей и боксплот, а также отметить медиану и среднее арифметическое на каждом из них.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;sns.set_palette('rainbow')
fig = plt.figure(figsize = (20, 15))
ax = fig.add_subplot(1, 1, 1)
sns.histplot(groupped_df, x = 'total_bill', binwidth=200, alpha=0.6, ax=ax)
plt.xlabel(&amp;quot;Покупки&amp;quot;, fontsize=25)
plt.ylabel(&amp;quot;Суммы чеков&amp;quot;, fontsize=25)
plt.title(&amp;quot;Распределение суммы чеков&amp;quot;, fontsize=25)
plt.axvline(mean_bill, linewidth=2, color='r', alpha=1, linestyle='--', label = 'Среднее = {:.0f}'.format(mean_bill))
plt.axvline(median_bill, linewidth=2, color='darkgreen', alpha=1, linestyle='--', label = 'Медиана = {:.0f}'.format(median_bill))
plt.legend(fontsize=&amp;quot;xx-large&amp;quot;)
plt.show()&lt;/code&gt;&lt;/pre&gt;&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/avg_invoice_33.jpg" width="1440" height="1080" alt="" /&gt;
&lt;/div&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;fig = plt.figure(figsize = (10, 10))
sns.set_theme(style=&amp;quot;whitegrid&amp;quot;)
sns.set_palette(palette=&amp;quot;pastel&amp;quot;)

sns.boxplot(y = groupped_df['total_bill'], showfliers = False)

plt.scatter(0, 1800, marker='*', s=100, color = 'black', label = 'Выбросы')
plt.scatter(0, 1850, marker='*', s=100, color = 'black')
plt.scatter(0, 1900, marker='*', s=100, color = 'black')
plt.scatter(0, mean_bill, marker='o', s=100, color = 'red', edgecolors = 'black', label = 'Среднее')
plt.legend()

plt.ticklabel_format(axis='y', style='plain')
plt.ylabel(&amp;quot;Сумма чека&amp;quot;, fontsize=15)
plt.title(&amp;quot;Боксплот суммы чеков&amp;quot;, fontsize=15)
plt.show()&lt;/code&gt;&lt;/pre&gt;&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/bp_invoice.jpg" width="720" height="720" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Из графиков следует, что распределение смещено к началу координат (отличное от нормального), а значит медиана и среднее не равны. Медианное значение меньше среднего примерно на 220 рублей.&lt;br /&gt;
Теперь представим, что у маркетологов есть задача повысить средний чек покупателя. Маркетолог может решить, что поскольку средний чек равен 601 рублю, то можно предложить следующую акцию: «Всем покупателям, кто совершит покупку на 600 рублей, мы предоставляем скидку 20% на товар за 100 рублей». В целом, резонное предложение, однако, в реальности, средний чек ниже — 378 рублей. То есть большая часть покупателей не заинтересуется в предложении, поскольку их покупка обычно не достигает предложенного порога. Это значит. что они не воспользуются предложением и не получат скидку, а компания не сможет достичь поставленной цели и увеличить прибыль супермаркета. Все дело в том, что исходные предпосылки были ошибочны.&lt;/p&gt;
&lt;h2&gt;Выводы&lt;/h2&gt;
&lt;p&gt;Как вы уже поняли, среднее арифметическое зачастую показывает более значимый и приятный результат, как для бизнеса, так и для исследовательских задач, ведь руководству всегда выгоднее представить ситуацию со средним чеком или демографической ситуацией в стране лучше, чем она есть на самом деле. Однако, необходимо всегда помнить о недостатках такой метрики, как среднее арифметическое, чтобы уметь грамотно выбрать подходящий аналог для оценки той или иной ситуации.&lt;/p&gt;
</description>
<pubDate>Thu, 16 Sep 2021 21:20:47 +0300</pubDate>
</item>

<item>
<title>Моделирование LTV в SQL</title>
<guid isPermaLink="false">115</guid>
<link>http://test.leftjoin.ru/all/modeling-ltv-with-sql/</link>
<comments>http://test.leftjoin.ru/all/modeling-ltv-with-sql/</comments>
<description>
&lt;p&gt;У большинства игровых и мобильных компаний имеется кривая Retention, ранее мы писали о том, &lt;a href="http://test.leftjoin.ru/all/retention-rate/"&gt;что такое Retention и как его посчитать&lt;/a&gt;. Вкратце — это метрика, которая позволяет понять насколько хорошо продукт вовлекает пользователей в ежедневное использование. А ещё при помощи Retention и ARPDAU можно посчитать LTV (Lifetime Value), пожизненный доход с одного пользователя. Зная средний доход с пользователя за день и кривую Retention мы можем смоделировать ее и спрогнозировать LTV.&lt;/p&gt;
&lt;p class="note"&gt;Для материала были взяты данные из одного реального игрового проекта. Нулевой день не отображен для того, чтобы видеть динамику в деталях&lt;/p&gt;
&lt;p&gt;В сегодняшнем материале мы подробно разберём, как смоделировать LTV для 180 дней при помощи SQL и просто линейной регрессии.&lt;/p&gt;
&lt;h2&gt;Как посчитать LTV?&lt;/h2&gt;
&lt;p&gt;В общем случае формула LTV выглядит как ARPDAU умноженное на Lifetime — время жизни пользователя в проекте.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/chart.png" width="223" height="19" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Посмотрим на классический график Retention:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-08-13--15.54.53.png" width="942" height="413" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Lifetime — это площадь фигуры под Retention:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-08-13--15.55.13.png" width="923" height="410" alt="" /&gt;
&lt;/div&gt;
&lt;p class="note"&gt;Откуда взялись интегралы и площади можно подробнее узнать в &lt;a href="https://gdcuffs.com/ltv-integrals-and-areas/"&gt;этом материале&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Значит, чтобы посчитать Lifetime, нужно взять интеграл от функции удержания по времени. Формула приобретает следующий вид:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/chart-2.png" width="198" height="28" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Для описания кривой Retention лучше всего подходит степенная функция a*x^b. Вот как она выглядит в сравнении с кривой Retention:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-08-13--15.58.19.png" width="802" height="403" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;При этом x — номер дня, a и b — параметры функции, которую мы построим при помощи линейной регрессии. Регрессия появилась неслучайно — эту степенную функцию можно привести к виду линейной функции, логарифмируя:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/chart-4.png" width="64" height="18" alt="" /&gt;
&lt;/div&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/chart-5.png" width="154" height="20" alt="" /&gt;
&lt;/div&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/chart-6.png" width="156" height="19" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;ln(a) — intercept, b — slope. Остаётся найти эти параметры — в линейной регрессии для этого используют метод наименьших квадратов. Lifetime — кумулятивная сумма прогноза за 180 дней. Посчитав её, остаётся умножить Lifetime на ARPDAU и получим LTV за 180 дней.&lt;/p&gt;
&lt;h2&gt;Строим LTV&lt;/h2&gt;
&lt;p&gt;Перейдём к практике. Для всех расчётов мы использовали данные одной игровой компании и СУБД PostgreSQL — в ней уже реализованы функции поиска параметров для линейной регрессии. Начнём с построения Retention: соберём общее количество пользователей в период с 1 марта по 1 апреля 2021 года — мы изучаем активность за один месяц:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;--общее количество юзеров в когорте
with cohort as (
    select count(distinct id) as total_users_of_cohort
    from users
    where date(registration) between date '2021-03-01' and date '2021-03-30'
),&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь посмотрим, как ведут себя эти пользователи в последующие 90 дней:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;--количество активных юзеров на 1ый день, 2ой, 3ий и тд. из когорты
active_users as (
    select date_part('day', activity.date - users.registration) as activity_day, 
               count(distinct users.id) as active_users_of_day
    from activity
    join users on activity.user_id = users.id
    where date(registration) between date '2021-03-01' and date '2021-03-30' 
    group by 1
    having date_part('day', activity.date - users.registration) between 1 and 90 --берем только первые 90 дней, остальные дни предсказываем.
),&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Кривая Retention — отношение количества активных пользователей к размеру когорты текущего дня. В нашем случае она выглядит так:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-08-13--15.54.53.png" width="942" height="413" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;По данным кривой посчитаем параметры для линейной регрессии. regr_slope(x, y) — функция для вычисления наклона регрессии, regr_intercept(x, y) — функция для вычисления перехвата по оси Y. Эти функции являются стандартными &lt;a href="https://www.postgresql.org/docs/9.4/functions-aggregate.html"&gt;агрегатными функциями в PostgreSQL&lt;/a&gt; и для известных X и Y по методу наименьших квадратов.&lt;/p&gt;
&lt;p&gt;Вернёмся к нашей формуле — мы получили линейное уравнение, и хотим найти коэффициенты линейной регрессии. Перехват по оси Y и коэффициент наклона можем найти по дефолтным для PostgreSQL функциям. Получается:&lt;/p&gt;
&lt;p class="note"&gt;Подробнее о том, как работают функции intercept(x, y) и slope(x, y) можно почитать в &lt;a href="https://www.mathsisfun.com/data/least-squares-regression.html"&gt;этом мануале&lt;/a&gt;&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/chart-6.png" width="156" height="19" alt="" /&gt;
&lt;/div&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/chart-10.png" width="217" height="21" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Из свойства натурального логарифма следует, что:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/chart-11.png" width="165" height="25" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Наклон считаем аналогичным образом:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/chart-12.png" width="157" height="21" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Эти же вычисления запишем в подзапрос для расчёта коэффициентов регрессии:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;--рассчитываем коэффициенты регрессии
coef as (
    select exp(regr_intercept(ln(activity), ln(activity_day))) as a, 
                regr_slope(ln(activity), ln(activity_day)) as b
    from(
                select activity_day,
                            active_users_of_day::real / total_users_of_cohort as activity
                from active_users 
                cross join cohort order by activity_day 
            )
),&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;И получим прогноз на 180 дней, подставив параметры в степенную функцию, описанную ранее. Заодно посчитаем Lifetime — кумулятивную сумму спрогнозированных данных. В подзапросе coef мы получим только два числа — параметр наклона и перехвата. Чтобы эти параметры были доступны каждой строке подзапроса lt, делаем cross join к coef:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;lt as(
    select generate_series as activity_day,
               active_users_of_day::real/total_users_of_cohort as real_data,
               a*power(generate_series,b) as pred_data, 	 
               sum(a*power(generate_series,b)) over(order by generate_series) as cumulative_lt
    from generate_series(1,180,1)
    cross join coef
    join active_users on generate_series = activity_day::int
),&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Сравним прогноз на 180 дней с Retention:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-08-13--15.30.59.png" width="936" height="425" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Наконец, считаем сам LTV — Lifetime, умноженный на ARPDAU. В нашем случае ARPDAU равняется $83.7:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;select cumulative_lt as LT,
           cumulative_lt * 83.7 as LTV
from lt&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Наконец, построим график LTV на 180 дней:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-08-13--15.51.05.png" width="852" height="416" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Весь запрос:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;--общее количество юзеров в когорте
with cohort as (
    select count(*) as total_users_of_cohort
    from users
    where date(registration) between date '2021-03-01' and date '2021-03-30'
),
--количество активных юзеров на 1ый день, 2ой, 3ий и тд. из когорты
active_users as (
    select date_part('day', activity.date - users.registration) as activity_day, 
               count(distinct users.id) as active_users_of_day
    from activity
    join users on activity.user_id = users.id
    where date(registration) between date '2021-03-01' and date '2021-03-30' 
    group by 1
    having date_part('day', activity.date - users.registration) between 1 and 90 --берем только первые 90 дней, остальные дни предсказываем.
),
--рассчитываем коэффициенты регрессии
coef as (
    select exp(regr_intercept(ln(activity), ln(activity_day))) as a, 
                regr_slope(ln(activity), ln(activity_day)) as b
    from(
                select activity_day,
                            active_users_of_day::real / total_users_of_cohort as activity
                from active_users 
                cross join cohort order by activity_day 
            )
),
lt as(
    select generate_series as activity_day,
               active_users_of_day::real/total_users_of_cohort as real_data,
               a*power(generate_series,b) as pred_data, 	 
               sum(a*power(generate_series,b)) over(order by generate_series) as cumulative_lt
    from generate_series(1,180,1)
    cross join coef
    join active_users on generate_series = activity_day::int
),
select cumulative_lt as LT,
            cumulative_lt * 83.7 as LTV
from lt&lt;/code&gt;&lt;/pre&gt;</description>
<pubDate>Mon, 16 Aug 2021 09:32:37 +0300</pubDate>
</item>

<item>
<title>Анализ альбомов Земфиры: дашборд в Tableau</title>
<guid isPermaLink="false">112</guid>
<link>http://test.leftjoin.ru/all/zemfira-tableau/</link>
<comments>http://test.leftjoin.ru/all/zemfira-tableau/</comments>
<description>
&lt;p&gt;&lt;a href="http://test.leftjoin.ru/tableau/zemfira.html" style="text-decoration:none; border:0"&gt;&lt;img src="http://test.leftjoin.ru/pictures/zemfira.png.jpg" border="0" width="150%" height="150%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;В марте мы опубликовали исследование &lt;a href="http://test.leftjoin.ru/all/borderline-text-analysis/" class="nu"&gt;«&lt;u&gt;Python и тексты нового альбома Земфиры: анализируем суть песен&lt;/u&gt;»&lt;/a&gt;, в котором при помощи Word2Vec-модели проанализировали близость песен альбома «бордерлайн» и получили самые близкие слова по духу альбома — ими оказались «пламень», «гореть», «тоска», «печаль», «сердце», «солнце» и другие.&lt;/p&gt;
&lt;p&gt;Мы продолжили работу над альбомами Земфиры и проанализировали семь из них, а затем результаты собрали в один дашборд и опубликовали его в &lt;a href="http://test.leftjoin.ru/tableau/zemfira.html"&gt;Tableau Public&lt;/a&gt;. Посмотрите, что получилось.&lt;/p&gt;
&lt;p&gt;Заглавная страница — общий анализ семи альбомов Земфиры. Переключиться на конкретный альбом можно по нажатию на его иконку внизу страницы. Для каждого альбома представлена матрица семантической близости песен, облако слов и топ схожих слов для альбома.&lt;/p&gt;
</description>
<pubDate>Thu, 08 Jul 2021 13:56:12 +0300</pubDate>
</item>

<item>
<title>Анализируем речь в Python: О чем говорят гости youtube-канала вДудь</title>
<guid isPermaLink="false">111</guid>
<link>http://test.leftjoin.ru/</link>
<comments>http://test.leftjoin.ru/</comments>
<description>
&lt;p&gt;Сегодня при помощи ML мы будем анализировать прямую речь. В качестве данных используем интервью, которые журналист Юрий Дудь берет для своего YouTube-канала.&lt;br /&gt;
Выход практически каждого ролика на канале «вДудь» считается событием, а некоторые из этих релизов даже сопровождаются скандалами из-за неосторожных высказываний его гостей.&lt;br /&gt;
Посмотрим с помощью Python и лемматизации о чем таком интересном рассказывали герои роликов канала «вДудь».&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Парсим тексты субтитров&lt;/b&gt;&lt;br /&gt;
В этом проекте мы будем использовать библиотеки, которые обрабатывают тексты, но сначала нам нужно эти тексты добыть. Импортируем API-интерфейс Python &lt;span class="inline-code"&gt;youtube_transcript_api&lt;/span&gt;, который скачивает субтитры из видео на YouTube.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import pandas as pd
import numpy as np

from youtube_transcript_api import YouTubeTranscriptApi
import json&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Предобработаем URL видео для скачивания субтитров. Всего мы собрали 100 роликов с интервью. В некоторых из интервью нет подготовленных субтитров. В файле &lt;span class="inline-code"&gt;‘dud.csv’&lt;/span&gt; заранее подготовлен список гостей канала вДудь с ссылками на их интервью.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def new_url(s):
    return s.replace('watch?v=','').replace('be.com','.be').replace('www.','')

def url_to_id(s):
    return s.partition('be/')[2]

df = pd.read_csv('dud.csv')
df['URL'] = df['URL'].apply(new_url)
df['video_id'] = df['URL'].apply(url_to_id)
df = df.set_index(keys='Гость')&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;У нас теперь есть датафрейм, в котором пока только информация о гостях и ссылка на видео. Но это пока.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-06-01--10.49.04.png" width="638" height="407" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Загрузим в нашу таблицу субтитры интервью. Если субтитры найти не удалось, то выведем на экран имена людей, к интервью с которыми их нет или они отключены (или субтитры есть, но не на русском языке).&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;texts = []
no_sub = []
for speaker in df.index:
    video_id = df.loc[speaker,'video_id']
    try:
        data = YouTubeTranscriptApi.get_transcript(video_id, languages=['ru', 'ru'])
        data = ' '.join([words['text'] for words in data])
    except Exception:
        print('Нет Субтитров для: ', speaker)
        no_sub.append(speaker)
        data = &amp;quot;&amp;quot;
    texts.append(data)
df['text'] = texts
df.to_csv('df_dud.csv')&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;У девяти из 100 интервьюируемых субтитров не оказалось и нам вернулся такой текст:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;Нет Субтитров для:  L'one
Нет Субтитров для:  Шнур
Нет Субтитров для:  Ресторатор
Нет Субтитров для:  Амиран
Нет Субтитров для:  Ильич
Нет Субтитров для:  Соболев
Нет Субтитров для:  Иван Дорн
Нет Субтитров для:  Навальный
Нет Субтитров для:  Noize MC&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Анализируем тексты&lt;/b&gt;&lt;br /&gt;
Анализ текстовой информации сложен в той степени, в какой сложен язык, на котором написан текст. Самый популярный способ решения такой аналитической задачи — стемминг. Стеммингом называют процесс нахождения стема — основы слова. Для стемминга используют библиотеку NLTK (Natural Language Toolkit), которая содержит правила образования стемов.&lt;br /&gt;
Этот метод хорошо работает с английскими словами, но у русского языка слишком сложно устроена морфология образования слов, что повышает вероятность ошибки. Стемминг будет хорошим выбором для анализа строк, содержание которых вы примерно представляете себе (например, когда пользователя просят заполнить форму).&lt;br /&gt;
Для нашего кейса лучше выбрать лемматизацию — приведение слова к его словарной форме. Проведя лемматизацию текстовых данных по правилам русского языка мы получим существительные в именительном падеже единственного числа (кошками — кошка), прилагательные в именительном падеже мужского рода (пушистая — пушистый), а глаголы в инфинитиве несовершенного вида (бежит — бежать). В этом проекте мы используем MyStem и Pymorphy. Обе библиотеки представляют собой морфологические анализаторы.&lt;br /&gt;
Кроме того, поскольку при анализе  мы будем использовать алгоритмы машинного обучения, то нам нужно избавиться от слов, которые часто встречаются, но не несут какой-то ценности для анализа. В противном случае они могут повлиять на работу модели. Список таких стоп-слов возьмем из библиотеки &lt;span class="inline-code"&gt;nltk.corpus&lt;/span&gt;.&lt;br /&gt;
Максимально подробно о подготовке текста к анализу мы рассказывали в материале &lt;a href="http://test.leftjoin.ru/all/borderline-text-analysis/" class="nu"&gt;«&lt;u&gt;Python и тексты нового альбома Земфиры&lt;/u&gt;»&lt;/a&gt;. Тут была проведена идентичная работа подготовка текстов, после чего мы посчитали количество уникальных слов (’Unique Words’) и записали, как часто они встречаются в речи собеседников Дудя (‘PPT Unique Words’).&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;df['Total Words'] = df['text'].apply(number_words)
df['Unique Words'] = df['text'].apply(set).apply(len)
df['PPT Unique Words'] = df['Unique Words'] / df['Total Words'] * 100
df['PPT Unique Words'] = df['PPT Unique Words'].apply(lambda x: round(x,2))
df.to_csv('df_dud.csv')&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Строим облако слов&lt;/b&gt;&lt;br /&gt;
Автоматизируем построение облака слов для каждого гостя Дудя. Таким образом мы узнаем какие слова встречаются в их речи чаще всего. Для визуализации инсталлируем &lt;span class="inline-code"&gt;wordcloud&lt;/span&gt;, а &lt;span class="inline-code"&gt;word_tokenize&lt;/span&gt; подсчитает количество слов, которые будут встречаться чаще всего.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import nltk
from wordcloud import WordCloud
import pandas as pd
import matplotlib.pyplot as plt
from nltk import word_tokenize, ngrams

def word_cloud(df, occup=None, general=True):
    if occup:
        df = df[df['Род деятельности'] == occup] 
    if general:
        data_source = zip([occup], [' '.join([el for el in df['Prepared Text']])])
        col_count, row_count = 1, 1
    else:
        data_source = zip([el for el in df.index], df['Prepared Text']) 
        col_count = max(1, df.shape[0] // 3)
        row_count = df.shape[0] // col_count + 1
        
    fig = plt.figure()
    plt.figure(figsize=(10, 10))
    fig.patch.set_facecolor('white')
    plt.subplots_adjust(wspace=0.3, hspace=0.2)
    i = 1
    for name, text in data_source:
        tokens = word_tokenize(text)
        text_raw = &amp;quot; &amp;quot;.join(tokens)
        wordcloud = WordCloud(colormap='PuBu', background_color='white', contour_width=10).generate(text_raw)
        plt.subplot(row_count, col_count, i, label=name,frame_on=True)
        plt.tick_params(labelsize=10)
        plt.imshow(wordcloud)
        plt.axis(&amp;quot;off&amp;quot;)
        plt.title(name,fontdict={'fontsize':12,'color':'grey'},y=1.0)
        plt.tick_params(labelsize=10)
        i += 1
    plt.savefig(f'./word_cloud/{occup}.png', dpi=900)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;У нас получились вот такие облака слов по каждому из гостей программы:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-06-02--18.58.17.png" width="1198" height="678" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;&lt;b&gt;Работа с Word2vec&lt;/b&gt;&lt;br /&gt;
С помощью библиотеки &lt;span class="inline-code"&gt;gensim&lt;/span&gt; вызываем модуль, который должен представить слова в наших текстах как векторы.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;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()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Для работы модели используем бинарный файл &lt;span class="inline-code"&gt;‘model.bin’&lt;/span&gt;:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;model = gensim.models.KeyedVectors.load_word2vec_format('model.bin', binary=True)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Модель &lt;b&gt;Word2Vec&lt;/b&gt; основана на нейронных сетях и позволяет представлять слова в виде векторов, учитывая семантическую составляющую. Ее мы уже использовали в анализе лирики Земфиры. Косинусная мера семантически схожих слов будет стремиться к 1, а  у двух слов, не имеющих ничего общего по смыслу, она близка к 0.&lt;br /&gt;
Напишем функцию, которая будет принимать список слов из наших интервью, распознавать для каждого часть речи, а затем получать и суммировать вектора — так мы сможем находить вектора не для одного слова, а для целых предложений и текстов.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;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&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Для каждого интервью находим вектор и собираем соответствующий столбец в датафрейм:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;vec_list = []
for word in df['Prepared Text']:
    vec_list.append(get_vector(word.split()))
df['Vector'] = vec_list&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Напишем функцию, который будет подсчитывать N-граммы для каждого гостя:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_top_five_ngrams(text, n):
    counter = collections.Counter()
    bigrams = list(ngrams(text, n))
    counter.update(bigrams)
    return counter.most_common()[:10]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Построим топ N-грамм в соответствии с группой:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;top_words = dict.fromkeys(df.index)
for person in df.index:
    text = df.loc[person,'Prepared Text']
    n_gram = get_top_five_ngrams(text.split(), 1)
    n_list = []
    for item in n_gram:
        n_list.append(item[0][0])
    top_words[person] = n_list
ordered_pesrons = df.index
top_2_words = []
for person in ordered_pesrons:
    top_2_words.append(top_words[person])
df['Top bigramms'] = top_2_words&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Напишем функцию, которая будет добавлять самые часто встречающиеся слова в речи интервьюируемого:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def top_similar(df, occup=None, agg='Person'):
    if occup:
        df = df[df['Род деятельности'] == occup]
    if agg == 'Person':
        top_words_person = dict.fromkeys(df.index)
        for person in df.index:
            vec = df.loc[person, 'Vector']
            words = model.similar_by_vector(vec, topn=10)
            top_words_person[person] = [el[0].split('_')[0] for el in words]
        df_person_words = pd.DataFrame(columns=[agg,'Top Words'])
    elif agg == 'Total':
        top_words_person = {'Total':0}
        vec = df['Vector'].sum()
        words = model.similar_by_vector(vec, topn=10)
        top_words_person['Total'] = [el[0].split('_')[0] for el in words]
        df_person_words = pd.DataFrame(columns=[agg,'Top Words'])
    
    for k,v in top_words_person.items():
        df_person_words = df_person_words.append({agg:k, 'Top Words':v},ignore_index=True)
    df_person_words = df_person_words.set_index(keys=agg) 
    
    return df_person_words&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Для дальнейшей работы группируем гостей по цеховой принадлежности. Наверное, можно ожидать, что режиссеры будут обсуждать кино и все, что с ним связано, а музыканты — музыку.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;df_occup = pd.DataFrame(columns=['Occupation', 'Top Words'])
for occup in df['Род деятельности'].unique():
    words = top_similar(df, occup=occup, agg='Total')['Top Words'][0]
    df_occup = df_occup.append({'Occupation':occup, 'Top Words’:words},ignore_index=True)

for i in range(10):
    df_occup[f'Top {i+1} word'] = df_occup['Top Words'].apply(lambda x: x[i])&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;У нас получится новый датафрейм с топом слов для каждой категории гостей (музыкант, политик, актер и тд).&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-06-02--20.55.04.png" width="1104" height="631" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;&lt;b&gt;Анализ риторики гостя&lt;/b&gt;&lt;br /&gt;
Используя метод &lt;span class="inline-code"&gt;similar_by_vector&lt;/span&gt; для каждого из видов деятельности интервьюируемых, мы получаем список слов, которые наиболее точно описывают тематику текстов.&lt;br /&gt;
Стоит отметить, что слово «государство» стоит на первом месте не только в интервью политиков и бизнесменов, но и дизайнеров с писателями. Очевидно, что тема разговора у всех профессиональных групп смещена в сторону политики.&lt;br /&gt;
Актёры, кинокритики и музыканты описываются вполне закономерными для их сфер деятельности словами. А вот у фотографов нет ни слова про фотографию или творчество, но есть «работа», «трудоустройство», «существовать» и “семья”.&lt;br /&gt;
Сравним риторику героев, построив box plot для каждой категории с помощью &lt;span class="inline-code"&gt;plotly&lt;/span&gt;.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import plotly.express as px

l = []
for el,ind in zip(df['Род деятельности'].value_counts(), df['Род деятельности'].value_counts().index):
    if el &amp;gt; 1:
        l.append(ind)

df_kpi = df[df['Род деятельности'].isin(l)]
for kpi in ['Total Words', 'Unique Words','PPT Unique Words']:
    buf_df = df_kpi[['Род деятельности',kpi]]
    fig = px.box(df_kpi, 
                 x='Род деятельности',
                 y=kpi,
                )
    fig.show()&lt;/code&gt;&lt;/pre&gt;&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Bespalova/3.embed?link=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;p&gt;Наиболее разговорчивыми гостями оказались блогеры — и в среднем, и по медиане они наговорили больше всего слов. И опередили по этому показателю даже писателей. А вот самыми немногословными оказались рэперы, хотя, казалось бы, вот кто должен быть хорош в импровизации.&lt;/p&gt;
&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Bespalova/5.embed?link=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;p&gt;Что касается количества уникальных слов, то и тут блогеры значительно ушли вперед. Согласно медианным значениям, тройка лидеров выглядит так — блогер, журналист и писатель. А вот словарный запас рэперов оставляет желать лучшего.&lt;/p&gt;
&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Bespalova/1.embed?link=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;p&gt;Если говорить об отношении уникальных слов к общему количеству, то у всех групп гостей примерно одинаковый медианный показатель. Наиболее вариативными оказались музыканты — усы от их ящика показываю наибольший разброс значений.&lt;/p&gt;
</description>
<pubDate>Mon, 07 Jun 2021 11:26:28 +0300</pubDate>
</item>

<item>
<title>Парсим вакансии для аналитиков из Indeed</title>
<guid isPermaLink="false">110</guid>
<link>http://test.leftjoin.ru/all/parser-indeed-with-python/</link>
<comments>http://test.leftjoin.ru/all/parser-indeed-with-python/</comments>
<description>
&lt;p&gt;В этом материале мы расскажем, как парсить вакансии с сайта Indeed. Indeed — это крупнейший в мире поисковик вакансий. Этим текстом мы начинаем большой проект по анализу и визуализации показателей оплаты труда в области Data Science в разных странах.&lt;br /&gt;
Подобный анализ рынка вакансий, но только в России, мы проводили в материале &lt;a href="http://test.leftjoin.ru/all/hh-dashboard-bi-and-analysts-market/"&gt;Анализ рынка вакансий аналитики и BI: дашборд в Tableau&lt;/a&gt;, когда парсили данные с сайта HeadHunter.&lt;/p&gt;
&lt;p class="note"&gt;А еще у нас можно почитать материал  &lt;a href="http://test.leftjoin.ru/all/parsim-dannye-kataloga-sayta-ispolzuya-beautiful-soup-i-selenium/"&gt;Парсим данные каталога сайта, используя Beautiful Soup и Selenium&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Импорт библиотек&lt;/b&gt;&lt;br /&gt;
Библиотека &lt;span class="inline-code"&gt;fake_useragent&lt;/span&gt; имитирует реальный User-Agent, чтобы преодолеть защиту сайта от парсинга. Таким образом мы сможем пройти проверку HTTP заголовка User-Agent.&lt;br /&gt;
Модуль &lt;span class="inline-code"&gt;urllib.parse&lt;/span&gt; разбирает URL-адрес на компоненты и записывает его как кортеж. Он пригодится для перехода на карточки вакансий. BeautifulSoup поможет разобраться в структуре html-страницы и добыть нужную нам информацию.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import requests
from datetime import timedelta, datetime
import urllib.parse
from fake_useragent import UserAgent
from bs4 import BeautifulSoup
import pandas as pd
import time
from lxml.html import fromstring
from clickhouse_driver import Client
from clickhouse_driver import errors
import numpy as np
from funcs import check_title, get_skills_row, parse_salary, get_sheetname, create_table&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Создадим таблицу в Clickhouse&lt;/b&gt;&lt;br /&gt;
Данные, которые мы собираемся собрать, будем хранить в базе Clickhouse.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;create_table = '''CREATE TABLE if not exists indeed.vacancies (
    row_idx UInt16,
    query_string String,
    country String,
    title String,
    company String,
    city String,
    job_added Date,
    easy_apply UInt8,
    company_rating Nullable(Float32),
    remote UInt8,
    job_id String,
    job_link String,
    sheet String,
    skills String,
    added_date Date,
    month_salary_from_USD Float64,
    month_salary_to_USD Float64,
    year_salary_from_USD Float64,
    year_salary_to_USD Float64,
)
ENGINE = ReplacingMergeTree
SETTINGS index_granularity = 8192'''&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Обход блокировок&lt;/b&gt;&lt;br /&gt;
Нам нужно обойти защиту Indeed и избежать блокировки по IP. Для этого используем анонимные прокси адреса на сайте free-proxy-list.net. Как собрать свежие прокси, мы писали в нашем предыдущем тексте &lt;a href="http://test.leftjoin.ru/all/selenium-proxy/" class="nu"&gt;«&lt;u&gt;Пишем парсер свежих прокси на Python для Selenium&lt;/u&gt;»&lt;/a&gt;. Прокси адреса мы запишем в массив, который понадобится в момент обращения к Indeed, когда запрос будет проверять User-Agent.&lt;/p&gt;
&lt;p&gt;Данный метод удаляет IP из списка с прокси в том случае, если ответ от Indeed через него так и не пришел.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def remove_proxy_from_list_and_update_if_required(proxy):
    global _proxies
    _proxies.remove(proxy)
    if len(_proxies) == 0:
        update_proxy_list()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Функция, используя прокси, возвращает нам страницу Indeed, из которой мы впоследствии спарсим данные.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_page(updated_url, session):
    proxy = get_proxy()
    proxy_dict = {&amp;quot;http&amp;quot;: proxy, &amp;quot;https&amp;quot;: proxy}
    logger.info(f'try with proxy: {proxy}')
    try:
        session.proxies = proxy_dict
        return session.get(updated_url, timeout=15)
    except (requests.exceptions.RequestException, requests.exceptions.ProxyError, requests.exceptions.ConnectTimeout,
            requests.exceptions.ReadTimeout, requests.exceptions.SSLError,
            requests.exceptions.ConnectionError, url_ex.MaxRetryError, ConnectionResetError,
            socket.timeout, url_ex.ReadTimeoutError):
        remove_proxy_from_list_and_update_if_required(proxy)
        logger.info(f'try with proxy {proxy}')
        return get_page(updated_url, session)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Методы для парсера&lt;/b&gt;&lt;br /&gt;
Искомые данные нужно будет искать по тегам и атрибутам верстки с помощью BeautifulSoup. Мы заранее собрали ключевые слова, которые нас будут интересовать в вакансиях, и подготовили с ними отдельный датасет.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-05-26--10.28.08.png" width="1078" height="686" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;В карточках вакансий нет точной даты публикации, указано лишь сколько дней назад она была опубликована. Сохраним точную дату публикации в традиционном формате с помощью &lt;span class="inline-code"&gt;timedelta&lt;/span&gt;.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def raw_date_to_str(raw_date):
    raw_date = raw_date.lower()
    if '+' in raw_date or &amp;quot;более&amp;quot; in raw_date:
        delta = timedelta(days=32)
        return (datetime.now() - delta).strftime(&amp;quot;%Y-%m-%d&amp;quot;)
    else:
        parts = raw_date.split()
        for part in parts:
            if part.isdigit():
                delta = timedelta(days=part.isdigit())
                return (datetime.now() - delta).strftime(&amp;quot;%Y-%m-%d&amp;quot;)
    return &amp;quot;&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Сохраним id вакансии в системе Indeed. Подставляя id в URL страницы, мы сможем получить доступ к полному описанию вакансий.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_job_id_from_card(card):
    try:
        return card['id'].split('_')[1]
    except:
        return &amp;quot;&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Данный метод соберет названия вакансий.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_title_from_card(card):
    try:
        job_title = card.find('a', {'class': 'jobtitle'}).text
        return job_title.replace('\n', '')
    except:
        return ''&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Аналогичным образом напишем методы, которые будут собирать данные о названии компании, времени публикации объявления, местоположении работодателя и рейтинге работодателя на портале.&lt;/p&gt;
&lt;p&gt;URL сайта Indeed пишется для разных стран по-разному. Для США это будет просто indeed.com, а локализации для других стран получают префиксом xx.indeed.com. Список с префиксами мы собрали в массив заранее из &lt;a href=""&gt;&lt;a href="https://opensource.indeedeng.io/api-documentation/docs/supported-countries/"&gt;https://opensource.indeedeng.io/api-documentation/docs/supported-countries/&lt;/a&gt; списка&lt;/a&gt; Indeed.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_link_from_card(card, card_country):
    try:
        if card_country == 'us':
            return f&amp;quot;https://indeed.com{card.find('a', {'class': 'jobtitle'})['href']}&amp;quot;
        else:
            return f&amp;quot;https://{card_country}.indeed.com{card.find('a', {'class': 'jobtitle'})['href']}&amp;quot;
    except:
        return &amp;quot;&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Спарсим описание вакансии, которое можно найти по тегу ’summary’. Именно там содержатся требования, которые предъявляют к кандидату.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_summary_from_card_and_transform_to_skills(card):
    try:
        smr = card.find('div', {'class': 'summary'}).text
        return get_skills_row(smr)
    except:
        return &amp;quot;&amp;quot;
Необходимые hard-skills из описания вакансий будем сверять со списком 'skills'. 
skills = [&amp;quot;python&amp;quot;, &amp;quot;tableau&amp;quot;, &amp;quot;etl&amp;quot;, &amp;quot;power bi&amp;quot;, &amp;quot;d3.js&amp;quot;, &amp;quot;qlik&amp;quot;, &amp;quot;qlikview&amp;quot;, &amp;quot;qliksense&amp;quot;,
          &amp;quot;redash&amp;quot;, &amp;quot;metabase&amp;quot;, &amp;quot;numpy&amp;quot;, &amp;quot;pandas&amp;quot;, &amp;quot;congos&amp;quot;, &amp;quot;superset&amp;quot;, &amp;quot;matplotlib&amp;quot;, &amp;quot;plotly&amp;quot;,
          &amp;quot;airflow&amp;quot;, &amp;quot;spark&amp;quot;, &amp;quot;luigi&amp;quot;, &amp;quot;machine learning&amp;quot;, &amp;quot;amplitude&amp;quot;, &amp;quot;sql&amp;quot;, &amp;quot;nosql&amp;quot;, &amp;quot;clickhouse&amp;quot;,
          'sas', &amp;quot;hadoop&amp;quot;, &amp;quot;pytorch&amp;quot;, &amp;quot;tensorflow&amp;quot;, &amp;quot;bash&amp;quot;, &amp;quot;scala&amp;quot;, &amp;quot;git&amp;quot;, &amp;quot;aws&amp;quot;, &amp;quot;docker&amp;quot;,
          &amp;quot;linux&amp;quot;, &amp;quot;kafka&amp;quot;, &amp;quot;nifi&amp;quot;, &amp;quot;ozzie&amp;quot;, &amp;quot;ssas&amp;quot;, &amp;quot;ssis&amp;quot;, &amp;quot;redis&amp;quot;, 'olap', ' r ', 'bigquery', 'api', 'excel']&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Эта функция разобьет ’summary’ на слова пробелом и проверит их на соответствие нашему списку. В датасет будут возвращаться совпадения с нашим списком hard-skills.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_skills_row(summary):
    summary = summary.lower()
    row = []
    for sk in skills:
        if sk in summary:
            row.append(sk)
    return ','.join(row)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;На выходе мы получим таблицу с примерно 30 тысячами строк.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-05-21--17.29.19.png" width="1105" height="658" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Полный код проекта можно посмотреть в нашем &lt;a href="https://github.com/valiotti/leftjoin/tree/master/indeed"&gt; репозитории&lt;/a&gt; на GitHub.&lt;/p&gt;
</description>
<pubDate>Thu, 27 May 2021 10:10:46 +0300</pubDate>
</item>


</channel>
</rss>