{
    "version": "https:\/\/jsonfeed.org\/version\/1",
    "title": "Блог об аналитике, визуализации данных, data science и BI, заметки с тегом: Data Analytics",
    "home_page_url": "http:\/\/test.leftjoin.ru\/tags\/data-analytics\/",
    "feed_url": "http:\/\/test.leftjoin.ru\/tags\/data-analytics\/json\/",
    "icon": "http:\/\/test.leftjoin.ru\/user\/userpic@2x.jpg",
    "author": {
        "name": "Николай Валиотти",
        "url": "http:\/\/test.leftjoin.ru\/",
        "avatar": "http:\/\/test.leftjoin.ru\/user\/userpic@2x.jpg"
    },
    "items": [
        {
            "id": "148",
            "url": "http:\/\/test.leftjoin.ru\/all\/skvoznoy-identifikator-reshenie-problemy-metchinga-personalnyh-d\/",
            "title": "Сквозной идентификатор: решение проблемы мэтчинга персональных данных студентов Refocus",
            "content_html": "<p><img src=http:\/\/test.leftjoin.ru\/pictures\/cover.png  border=“0” width=100% height=100%><\/p>\n<p>В системах сквозной аналитики ключевую роль играет правильная модель атрибуции. Без нее данные невозможно интерпретировать, и их ценность для бизнеса невелика. При этом важно понимать, что любая модель напрямую зависит от качества данных.<\/p>\n<p>Частая проблема с сырыми данными в том, что информация об одном клиенте дублируется или, напротив, противоречит друг другу в разных источниках.<\/p>\n<p>Кроме того, что предобработка данных — база для аналитика, без правильного объединения персональных данных в принципе сложно отследить клиентский путь. Значит, нужно настраивать процессы объединения неоднородных персональных данных.<\/p>\n<p>Сегодня в любом клиентском бизнесе воронки регистрации устроены таким образом, что клиенты попадают в базу множеством способов — часто через маркетинговые каналы, которых всегда много (рассылки, реклама, соцсети). В каждом таком канале может быть ссылка на форму подписки, регистрацию на платформе или чат, и один клиент часто проходит все эти этапы. Сразу же образуется путаница в идентификации, которая сильно влияет на качество данных и результаты аналитики, если ее не лечить.<\/p>\n<p>Мы столкнулись с этой проблемой, работая с одним из наших клиентов, и решили ее, создав сквозной идентификатор. Это уникальный номер, который присваивается реальному клиенту и дублируется во все источники, где есть данные об этом клиенте, тем самым избавляя от путаницы.<\/p>\n<h2>Кейс Refocus: данные и путь клиента<\/h2>\n<p>Мы разрабатывали кастомную систему сквозной аналитики для эдтех-стартапа Refocus. Данные каждого студента в системы Refocus попадали из нескольких источников и были записаны несколько раз — как минимум при регистрации на курс, при первом входе на образовательную платформу и при входе в чат сопровождения.<\/p>\n<p>В нашем случае мэтчинг был важнее всего по трем источникам из тринадцати:<\/p>\n<ul>\n<li><b>amoCRM,<\/b> где фиксируется весь клиентский путь студента;<\/li>\n<li><b>Discord,<\/b> где проходило сопровождение студентов;<\/li>\n<li><b>Thinkific,<\/b> сама образовательная платформа с курсами.<\/li>\n<\/ul>\n<p>Остальные источники, с которыми мы работали, либо не содержали данных студентов (например, цифры эффективности работы sales-менеджеров были завязаны на данных сотрудников и трекались через другие системы), либо дублировали информацию из указанных трех.<\/p>\n<p>В Discord и Thinkific данные попадали напрямую, от студентов при регистрации в системах, а затем подтягивались в amoCRM. Основные причины несовпадения клиентских данных как у Refocus, так и в похожих случаях — человеческий фактор (опечатки), наличие у людей более чем одного телефона или адреса почты и ограничения самих платформ, с которых приходят данные: разный заданный формат полей и их количество.<\/p>\n<p>Часть этих факторов может решаться корректировкой самой клиентской воронки. Правда, не все платформы позволяют одинаково настроить вводные поля, а просьбы вводить данные в конкретном формате не всегда работают и не страхуют от ошибок. Плюс, задача аналитиков — получить чистые данные в любом случае.<\/p>\n<h2>Задача и поиск решения<\/h2>\n<p>Данные в Refocus мы подгружали в хранилище в BigQuery напрямую из интересующих нас источников (рекламных кабинетов, LMS и т. д.), используя Python. В дальнейшем на этих данных строились дашборды в Tableau.<\/p>\n<p>Обнаружить проблему несложно — при создании хранилища и дальнейшей выгрузке данных из него мы в любом случае чистим датасет от дубликатов и несовпадений.<\/p>\n<p>Поля, в которых возникали ошибки и для которых нам важен был мэтчинг, чтобы правильно отследить клиентский путь:<\/p>\n<ul>\n<li>имя — да, люди иногда вводят разные вариации ФИО (Юлия, Юля и Бля — на деле один человек!);<\/li>\n<li>телефон — с кодом страны или без, с пробелами, дефисами или слитно;<\/li>\n<li>электронная почта — длинные строки сложного формата, в которых легко опечататься.<\/li>\n<\/ul>\n<p>Поначалу, пока количество студентов Refocus было относительно небольшим, достаточно было скриптов, которые объединяли данные по одному из этих полей. В полученных таблицах в Tableau проводился поиск строк с пустым значением в соответствующем поле — и вот видно всех студентов, чьи данные не сошлись.<\/p>\n<p>Количество таких строк было в пределах пары десятков, и трекать и объединять их было несложно вручную. Это делалось прямо в первоисточниках сотрудниками Refocus, которые могли поправить опечатки и ошибки у себя в системах. После этого наш код выгрузки в хранилище перезапускался и тянул уже чистые данные. Если после этого что-то не сходилось, то наши аналитики правили информацию на уровне базы данных.<\/p>\n<p>Но при росте компании в какой-то момент число студентов, потерянных при мэтчинге, могло достигать сотни за месяц. Пока ошибка обнаружится, данные поправят в источниках, а мы перезапустим код выгрузки, могло пройти несколько часов — а это критичный интервал. Да и перезапускать выгрузку каждый день ради нескольких несовпадений — неэффективно. Стало понятно, что масштаб проблемы требует более точного и универсального решения.<\/p>\n<p>Вообще, в такой ситуации возможны несколько вариантов. Можно бесконечно править скрипты мэтчинга, учитывая новые и новые случаи и создавая костыли. А можно, например, настроить алерты в оркестраторе процессов (в нашем случае  — Airflow), которые позволят моментально узнавать о появившемся несовпадении и объединять “потерянные” клиентские сущности по паре за раз. Но это все еще неполная автоматизация, и она только ускоряет, а не упрощает процесс.<\/p>\n<p>Руководствуясь соображениями эффективности, мы предложили ввести сквозной идентификатор — одно значение ID, присваиваемое одному клиенту после автоматической интеграции его данных из разных источников.<\/p>\n<h2>Реализация решения и рабочий процесс<\/h2>\n<p>Чтобы понять масштаб проблемы, мы начали с того, что создали таблицы несовпадающих персональных данных. Для этого мы использовали скрипты на Python. Эти скрипты объединяли данные из разных источников и создавали из них большую сводную таблицу. Для того, чтобы свести данные о студенте в одну сущность, использовался мэтчинг по адресу электронной почты. Мы попробовали мэтчить по имени, фамилии, телефону (который сначала надо было привести к одному формату!) и почте, и именно последний вариант показал самую высокую точность. Возможно, дело в том, что из всех данных почта имеет самый однородный формат, поэтому остается учитывать только опечатки.<\/p>\n<p>Например, нам нужно было мэтчить данные для создания дашборда по возвратам, о которых информация объединялась как раз из наших трех основных источников. В ранней версии скрипта данные отбирались таким образом:<\/p>\n<pre class=\"e2-text-code\"><code>WITH snapshot_ AS (\r\n      SELECT DISTINCT s.*,\r\n        IFNULL(ae.name, ap.name) as contact_name,\r\n        ap.phone, ae.email,\r\n        split(replace(trim(lower(ae.email)),' ',''),'@')[OFFSET(0)] as email_first_part,\r\n        ai.thinkific_id, ai.intercom_id, ac.student_id\r\n      FROM (\r\n        SELECT *,\r\n          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY updated_at DESC) as num_,\r\n        FROM `Differture.amocrm_leads_snapshot`\r\n      ) s\r\n      LEFT JOIN (\r\n        SELECT DISTINCT lead_id, contact_id, name, email,\r\n          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY contact_id) as num1_\r\n        FROM `Differture.amo_emails`\r\n      ) ae using(lead_id)\r\n      LEFT JOIN (\r\n        SELECT DISTINCT lead_id, contact_id, name, phone,\r\n          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY contact_id) as num2_\r\n        FROM `Differture.amo_phones`\r\n      ) ap using(lead_id)\r\n      LEFT JOIN `Differture.amo_contact_thinkific_intercom_match` ai using(lead_id)\r\n      LEFT JOIN `Differture.AmoContacts` ac on cast(ae.contact_id as string)=ac.amo_id\r\n      WHERE (num_=1 or num_ is null) and (num1_=1 or num1_ is null) and (num2_=1 or num2_ is null)\r\n        and s.pipeline_id in (4920421,5245535) and s.status_id=142 and lower(s.lead_name) not like '%test%'\r\n    )<\/code><\/pre><p>Как можно заметить, идея сквозного идентификатора здесь уже присутствует — фигурирует <tt>student_id<\/tt>. На самом деле, в этой версии скрипта это графа из AmoContacts — таблицы, в которой хранятся только данные из amoCRM. Никаких джойнов по <tt>student_id<\/tt> пока не происходит. А происходят по <tt>email_first_part<\/tt>, адресу почты до символа @:<\/p>\n<pre class=\"e2-text-code\"><code>select distinct * from th_amo_ds_rf\r\n    left join calendly ce using(email_first_part)\r\n    left join typeform_live tfl on email_first_part=tf_email_first_part\r\n    left join typeform tf using(email_first_part)\r\n    left join csat using(email_first_part)<\/code><\/pre><p>Первым шагом по практическому введению идентификатора была таблица <b>students_main_info<\/b>, созданная в BigQuery in-house специалистом Refocus. К сожалению, у нас нет доступа к коду, который использовался для присвоения идентификатора. Зато мы можем показать вид этой таблицы:<\/p>\n<div class=\"e2-text-table\">\n<table cellpadding=\"0\" cellspacing=\"0\" border=\"0\">\n<tr>\n<td style=\"text-align: left\">student_full_name<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_email<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_country_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_country_name<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_courses_ids<\/td>\n<td style=\"text-align: center\">array<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_courses_names<\/td>\n<td style=\"text-align: center\">array<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_cohort_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_cohort_name<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">cohort_community_manager_name<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">cohort_community_manager_email<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_onboarding_live_session_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_onboarding_live_session_time<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_onboarding_live_session_zoom_url<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">amo_contact_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">intercom_contact_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">thinkific_student_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">discord_user_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">discord_user_discord_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">discord_guild_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">discord_channel_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">discord_roles<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<\/table>\n<\/div>\n<p>В students_main_info хранились данные из нужных источников с общим идентификатором в первой строке, и объединение проходило через сравнение этого поля.<\/p>\n<p>При этом поле <tt>student_id<\/tt> использовалось пока не везде; также использовались другие поля этой таблицы — например, <tt>thinkific_student_id<\/tt> или <tt>discord_user_id<\/tt>.<\/p>\n<p>После выгрузки и мэтчинга данных с помощью students_main_info студентов, которые потерялись при объединении, стало меньше, чем при первой схеме мэтчинга. Так мы убедились, что движемся в верном направлении. Тем не менее, использование одной таблицы, которая содержит больше десятка полей обо всех имеющихся персональных данных, не очень эффективно. Данные в ней уже обработаны скриптом специалиста Refocus, и если надо сверить их с сырыми источниками или ввести новый критерий отслеживания, все придется менять на бэкенде.<\/p>\n<h2>Что получилось в итоге<\/h2>\n<p>После теста сквозного идентификатора через одну большую таблицу мы продолжили улучшать структуру данных на бэке. Вместо students_main_info усилиями специалиста Refocus появилась подробная сеть более мелких таблиц, которые могут обращаться друг к другу и лежат в одном хранилище с нашими таблицами сырых данных.<\/p>\n<p>Вот так выглядела схема соотношения этих таблиц:<\/p>\n<p><img src=http:\/\/test.leftjoin.ru\/pictures\/image-2.png  border=“0” width=100% height=100%><\/p>\n<p>А вот так выглядела основная таблица Students:<\/p>\n<p><img src=http:\/\/test.leftjoin.ru\/pictures\/students.png border=“0” width=40% height=40%><\/p>\n<p>В ней-то и находились основные персональные данные студентов с присвоенным идентификатором, и к ней можно было обращаться для мэтчинга из остальных источников.<\/p>\n<p>Остальные таблицы выглядели похоже: всегда было поле с идентификатором и информация о какой-то характеристике студента — когорта, курс, роль в дискорде и так далее.<\/p>\n<p>Финальный код, написанный нашими аналитиками,  объединял данные при выгрузке из хранилища, и больше не опирался на ненадежный мэтчинг через имейл.<\/p>\n<p>Сначала он отбирал собранные нами данные из amoCRM <tt>(amocrm_leads_snapshot)<\/tt> и объединял их с контактной информацией клиентов. Затем в таблицу добавлялось поле <tt>student_id<\/tt> и отбирались данные, которые понадобятся нам дальше.<\/p>\n<pre class=\"e2-text-code\"><code>WITH snapshot_ AS (\r\n      SELECT DISTINCT s.*,\r\n        ac.name as contact_name, ac.phone, ac.email,\r\n        split(replace(trim(lower(ac.email)),' ',''),'@')[OFFSET(0)] as email_first_part,\r\n        ac.intercom_id, ac.student_id\r\n      FROM (\r\n        SELECT *,\r\n          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY updated_at DESC) as num_,\r\n        FROM `Differture.amocrm_leads_snapshot`\r\n      ) s\r\n      LEFT JOIN (\r\n        select cast(al.amo_id as INT64) as lead_id, cast(ac.amo_id as INT64) as contact_id,\r\n          ac.name, emails as email, phone, student_id, ic.intercom_id,\r\n          ROW_NUMBER() OVER(PARTITION BY al.amo_id ORDER BY ac.amo_id) as num1_\r\n        from `Differture.AmoContacts` ac\r\n        left join `Differture.AmoLeads` al on al.amo_contact_id=ac.id\r\n        left join `Differture.IntercomContacts` ic using(student_id)\r\n        , unnest(ac.emails) emails\r\n      ) ac using(lead_id)\r\n      WHERE (num_=1 or num_ is null) and (num1_=1 or num1_ is null)\r\n        and s.pipeline_id in (4920421,5245535) and s.status_id=142 and lower(s.lead_name) not like '%test%'\r\n    )<\/code><\/pre><p>Теперь при создании общей таблицы о возвратах с данными из amo, Thinkific и Discord объединение проходило через student_id:<\/p>\n<pre class=\"e2-text-code\"><code>th_amo_ds_rf as (\r\n      select distinct * except (channel_id, channel),\r\n        ifnull(channel_id, 'Not in discord') as channel_id,\r\n        ifnull(channel, 'Not in discord') as channel\r\n      from thinkific_amo_refunds\r\n      full outer join discord using(student_id)\r\n    )<\/code><\/pre><p>Когда объединенные таблицы данных студентов были созданы, получить таблицы несовпадений можно было простой строкой кода в Tableau:<\/p>\n<p><img src=http:\/\/test.leftjoin.ru\/pictures\/filter.png border=“0” width=50% height=50%><\/p>\n<p>Пустое значение поля student_id означает, что мэтча не случилось — где-то информация расходилась слишком сильно и не подтянулась в таблицы с идентификатором. Раньше, до введения идентификатора, поиск был таким же, но обращался к полям почты, телефона или имени-фамилии.<\/p>\n<p>Ниже можно увидеть таблицу, где данные из Thinkific не совпадали с amoCRM после перехода на Student ID. В этом случае студент есть в LMS, значит, на курсе учится — но его либо нет в системе учета, либо данные в ней разнятся с LMS.<\/p>\n<p><img src=http:\/\/test.leftjoin.ru\/pictures\/unnamed.png  border=“0” width=100% height=100%><\/p>\n<p>А вот таблица, где данные из Discord не совпадали с amoCRM. Все так же, как выше — студент есть в чатах сопровождения, но не ищется по своим данным в amoCRM.<\/p>\n<p><img src=http:\/\/test.leftjoin.ru\/pictures\/discord.png  border=“0” width=100% height=100%><\/p>\n<p>Оба скриншота показывают количество несовпадений примерно за месяц. Как видно по этим таблицам, количество несовпадений уменьшилось с 80-90 до пары десятков — примерно на 75%. Это позволило сократить количество перезапусков кода выгрузки вручную и уменьшить затраты времени и технических ресурсов на поддержание системы.<\/p>\n<h2>Выводы<\/h2>\n<p>Сквозной идентификатор — эффективное решение проблемы мэтчинга персональных данных. Он позволяет максимально автоматизировать процесс отслеживания и устранения несовпадений или дубликатов клиентских сущностей при выгрузке данных для анализа. В случаях, когда объем данных в системе невелик, а у компании нет возможности выделить ресурсы на реализацию такого решения, можно воспользоваться и другими вариантами. Например, алерты в оркестраторе процессов хорошо справятся в ситуации, когда объединить данные — вопрос ручного запуска одного скрипта раз в неделю. Но сквозной идентификатор — наверное, самое универсальное из доступных решений, которое покроет большинство ошибок и заметно уменьшит погрешность в качестве данных.<\/p>\n",
            "date_published": "2024-09-16T17:57:31+03:00",
            "date_modified": "2024-09-16T17:57:05+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/students.png",
            "_date_published_rfc2822": "Mon, 16 Sep 2024 17:57:31 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "148",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/students.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/image-2.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/filter.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/unnamed.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/discord.png"
                ]
            }
        },
        {
            "id": "136",
            "url": "http:\/\/test.leftjoin.ru\/all\/youtube-interview-speech-analysis\/",
            "title": "Анализируем речь с помощью Python: Сколько раз в минуту матерятся на интервью YouTube-канала «вДудь»?",
            "content_html": "<p><img src=\"http:\/\/test.leftjoin.ru\/pictures\/13-1.jpg\" border=\"0\" width=\"100%\" height=\"100%\"><\/a><\/p>\n<p>Выход практически каждого ролика на канале «вДудь» считается событием, а некоторые из этих релизов даже сопровождаются скандалами из-за неосторожных высказываний его гостей.<br \/>\nСегодня при помощи статистических подходов и алгоритмов ML мы будем анализировать прямую речь. В качестве данных используем интервью, которые журналист Юрий Дудь (признан иностранным агентом на территории РФ) берет для своего YouTube-канала. Посмотрим с помощью Python, о чем таком интересном говорили в интервью на канале «вДудь».<\/p>\n<h2>Сбор данных<\/h2>\n<p>C помощью <a href=\"https:\/\/developers.google.com\/youtube\/v3\">YouTube API<\/a> мы получили список всех видео с канала Юрия Дудя, а также их метаинформацию. О том, как это сделать, вы можете узнать, например, из <a href=\"http:\/\/test.leftjoin.ru\/all\/youtube-api\/\">статьи нашего блога<\/a>.<br \/>\nЕсли вы уже слышали знаменитое “Юрий будет дуть, дуть будет Юрий”, то наверняка знаете, что на этом канале есть документальные фильмы, а также интервью, в которых участвуют сразу несколько гостей. Нас заинтересовали только те выпуски, в которых преимущественно говорит только один гость. Поэтому нам пришлось провести фильтрацию всех видео вручную.<br \/>\nДля дальнейшего анализа нам необходимо было получить длительности роликов. Это мы сделали с помощью GET-запросов к YouTube API. Результаты приходили в специфическом формате (для примера: “PT1H49M35S”), поэтому их нам пришлось распарсить и перевести в секунды.<br \/>\nИтак, мы получили датафрейм, состоящий из 122 записей:<\/p>\n<p><img src=\"http:\/\/test.leftjoin.ru\/pictures\/--1.png\" border=\"0\" width=\"100%\" height=\"100%\"><\/a><\/p>\n<p>На основе метаинформации по лайкам, комментариям и просмотрам мы построили следующий Bubble Chart:<\/p>\n<iframe width=\"730\" height=\"480\" frameborder=\"0\" scrolling=\"no\" src=\"\/\/plotly.com\/~satiukov.e\/29.embed?showlink=false\"><\/iframe>\n<p><\/a><\/p>\n<p>Так как наша цель — проанализировать речь в интервью, нам необходимо было получить текстовые составляющие роликов. В этом нам помог API-интерфейс <a href=\"https:\/\/pypi.org\/project\/youtube-transcript-api\/\">youtube_transcript_api<\/a>, который скачивает субтитры из видео на YouTube. Для каких-то роликов субтитры были прописаны вручную, но для большинства они были сгенерированы автоматически. К сожалению, для 10 видео субтитров не оказалось: беседы с L’one, Шнуром, Ресторатором, Амираном, Ильичом, Ильей Найшуллером, Соболевым, Иваном Дорном, Навальным, Noize MC. Причину их отсутствия мы, к сожалению, понять не смогли.<\/p>\n<h2>А гости кто?<\/h2>\n<p>Спектр рода деятельности гостей канала «вДудь» достаточно обширен, поэтому было решено пополнить исходные данные информацией о том, чем же в основном занимается приглашенный участник каждого интервью. К сожалению, ролики не сопровождаются четкими метками профессиональной принадлежности гостя, поэтому мы прописали эту информацию сами. На момент выгрузки данных последним видео на канале был разговор с комиком Дмитрием Романовым.<br \/>\nЕсли с идентификацией профессии каждого гостя мы не ошиблись, то вот такое распределение в итоге получается:<\/p>\n<iframe width=\"730\" height=\"480\" frameborder=\"0\" scrolling=\"no\" src=\"\/\/plotly.com\/~satiukov.e\/31.embed?showlink=false\"><\/iframe>\n<p><\/a><\/p>\n<p>Музыканты, рэперы и актеры — самые частые гости Юрия, скорее всего, они являются самыми интересными для автора и аудитории. Представителей научного сообщества (астрофизик, историк, экономист и т.д) наоборот, гораздо меньше, ведь научно-популярные интервью — прерогатива других интервьюеров.<\/p>\n<h2>Обработка текста<\/h2>\n<p>Анализ текстовой информации сложен в той степени, в какой сложен язык, на котором написан текст. Подробно о подготовке текста к анализу мы рассказывали в материале <a href=\"http:\/\/test.leftjoin.ru\/all\/borderline-text-analysis\/\" class=\"nu\">«<u>Python и тексты нового альбома Земфиры<\/u>»<\/a>. Тут была проведена идентичная работа.<br \/>\nКак и раньше, для решения аналитической задачи мы решили использовать такой подход как лемматизация, т. е. приведение слова к его словарной форме. Проведя лемматизацию текстовых данных по правилам русского языка, мы получим существительные в именительном падеже единственного числа (кошками — кошка), прилагательные в именительном падеже мужского рода (пушистая — пушистый), а глаголы в инфинитиве (бежит — бежать). В этом проекте мы опять воспользовались библиотекой <a href=\"https:\/\/pymorphy2.readthedocs.io\/en\/stable\/\">Pymorphy<\/a>, представляющую собой морфологический анализатор.<br \/>\nПомимо приведения к словарной форме нам потребовалось убрать из текстов часто встречающиеся слова, которые не несут ценности для анализа. Это было необходимо, потому что так называемые стоп-слова могут повлиять на работу используемой модели машинного обучения. Список таких слов мы взяли из пакета <a href=\"https:\/\/www.nltk.org\/api\/nltk.corpus.html\">ntlk.corpus<\/a>, а после расширили его, изучив тексты интервью. Конечно, мы также убрали все знаки пунктуации.<\/p>\n<h2>Анализ словарного запаса<\/h2>\n<p>После обработки текста мы посчитали для каждого интервью количество всех слов, а также абсолютное и относительное количество уникальных слов. Конечно, полученные значения неидеальны, так как, во-первых, для большинства интервью были получены автоматически сгенерированные субтитры, которые являются неточными, а во-вторых, тексты были очищены от лишней информации.<br \/>\nСперва мы решили наглядно представить основной массив лексики, которая звучит в интервью. После группировки интервью по роду деятельности гостя нам удалось это сделать и в этом нам помогла библиотека <a href=\"http:\/\/amueller.github.io\/word_cloud\/\">wordcloud<\/a>. У нас получились такие облака слов:<\/p>\n<p><img src=\"http:\/\/test.leftjoin.ru\/pictures\/All-wordclouds.png.jpg\" border=\"0\" width=\"100%\" height=\"100%\"><\/a><\/p>\n<p>Лейтмотивом всех интервью Юрия являются обсуждение России (политики, социальной жизни и других особенностей), уровня заработка гостей, а также непосредственно профессиональной деятельности гостя (это особенно заметно у представителей индустрии кинопроизводства).<br \/>\nДалее мы решили построить боксплот для количества слов для каждого рода деятельности (профессии, которые были представлены единственным гостем, мы не стали учитывать):<\/p>\n<iframe width=\"730\" height=\"480\" frameborder=\"0\" scrolling=\"no\" src=\"\/\/plotly.com\/~satiukov.e\/33.embed?showlink=false\"><\/iframe>\n<p><\/a><\/p>\n<p>Наиболее разговорчивыми гостями оказались блогеры. По медиане, они наговорили больше всего слов. Чуть поодаль от них журналисты и комики, а вот самыми немногословными оказались рэперы.<\/p>\n<iframe width=\"730\" height=\"480\" frameborder=\"0\" scrolling=\"no\" src=\"\/\/plotly.com\/~satiukov.e\/35.embed?showlink=false\"><\/iframe>\n<p><\/a><\/p>\n<p>Что касается количества уникальных слов, то тут ситуация аналогичная. И рэперы опять в аутсайдерах…<\/p>\n<iframe width=\"730\" height=\"480\" frameborder=\"0\" scrolling=\"no\" src=\"\/\/plotly.com\/~satiukov.e\/37.embed?showlink=false\"><\/iframe>\n<p><\/a><\/p>\n<p>Если говорить об отношении уникальных слов к общему количеству, то тут можно увидеть совершенно иную картину. Теперь впереди оказываются, рэперы, музыканты и бизнесмены. Предыдущие же лидеры, наоборот, становятся самыми последними.<br \/>\nКонечно, стоит отметить, что такие сравнения могут быть несправедливыми, так как длительность интервью у каждого гостя Дудя разная, а потому кто-то просто мог успеть наговорить больше слов, чем остальные. Наглядно в этом можно убедиться, взглянув на распределение длительности интервью по роду деятельности (для построения использовался тот же пул гостей, что и для боксплотов выше):<\/p>\n<iframe width=\"730\" height=\"480\" frameborder=\"0\" scrolling=\"no\" src=\"\/\/plotly.com\/~satiukov.e\/39.embed?showlink=false\"><\/iframe>\n<p><\/a><\/p>\n<p>К тому же, разные роды деятельности представляет разное количество человек, это тоже могло сказаться на результатах.<br \/>\nДалее мы составили список слов, появление которых в интервью было бы интересно отследить, и посмотрели как часто они упоминаются для каждого рода деятельности. Также мы решили учесть дисбаланс среди представителей разных профессиональных категорий и разделили полученные частоты на соответствующее количество гостей.<\/p>\n<p><img src=\"http:\/\/test.leftjoin.ru\/pictures\/-.png\" border=\"0\" width=\"90%\" height=\"90%\"><\/a><\/p>\n<p>Первое место по упоминаниям очевидно занимает Россия. Что касается Запада, то про США гости говорили в 2,5 раза меньше. Что касается лидера РФ, то про него речь заходила достаточно часто. Его оппонент, Алексей Навальный, в этой словесной “баталии” потерпел поражение. Интересно, что политики далеко не в топе по упоминаниям Путина. Впереди оказался экономист Сергей Гуриев, после него ведущий Александр Гордон, а тройку замкнули журналисты.<br \/>\nГлагол “любить” чаще использовали люди, имеющие отношение к искусству, творчеству и гуманитарным наукам — кинокритик Антон Долин, мультипликатор Олег Куваев, историк Тамара Эйдельман, актеры, рэперы, художник Федор Павлов-Андреевич, комики, музыканты, режиссеры. Про страхи (если судить по глаголу “бояться”) гости говорили реже, чем о любви. В топ вошли историк Эйдельман, дизайнер Артемий Лебедев, кинокритик Долин и политики. Может быть в этом кроется ответ на вопрос, почему же политики не так охотно произносили имя президента России.<br \/>\nЧто касается денег, то о них говорили все. Ну, за исключением человека науки, астрофизика Константина Батыгина. С церковью же имеем совершенно обратную ситуацию. О ней по большей части говорили только писатели и художник Павлов-Андреевич.<\/p>\n<h2>Анализ мата<\/h2>\n<p>Далее мы решили проанализировать то, как часто гости Юрия Дудя ругались матом. С помощью регулярных выражений мы составили словарь матерных слов со всех интервью. После этого, для каждого ролика было подсчитано суммарное количество вхождений элементов составленного словаря.<br \/>\nМы построили диаграммы, отражающие топ-10 любителей нецензурно выражаться по количеству “запрещенных” слов в минуту.<\/p>\n<iframe width=\"730\" height=\"480\" frameborder=\"0\" scrolling=\"no\" src=\"\/\/plotly.com\/~satiukov.e\/43.embed?showlink=false\"><\/iframe>\n<p><\/a><\/p>\n<p>Как видим, рэперы и музыканты почти полностью захватили топ. Помимо них очень часто ругались такие гости как блогер Данила Поперечный и комики Иван Усович и Алексей Щербаков. Первое место в рейтинге с большим отрывом от остальных держит Morgenstern (признан иностранным агентом на территории РФ), а вот Олег Тиньков в своем последнем интервью матерился не так много, чтобы попасть в Топ-10.<br \/>\nЗато, как искрометно!<\/p>\n<p><img src=\"http:\/\/test.leftjoin.ru\/pictures\/FSgosl8XwAAmtpw.jpg\" border=\"0\" width=\"100%\" height=\"100%\"><\/a><\/p>\n<p>После персонального анализа мы решили узнать, насколько насыщена нецензурными словами речь представителей разных профессиональных групп. Нулевые показатели при этом были опущены.<\/p>\n<iframe width=\"730\" height=\"480\" frameborder=\"0\" scrolling=\"no\" src=\"\/\/plotly.com\/~satiukov.e\/41.embed?showlink=false\"><\/iframe>\n<p><\/a><\/p>\n<p>Ожидаемо, что больше всех матерились рэперы. На втором месте оказались блогеры (по большей части за счет Поперечного). За ними следует Артемий Лебедев, единственный дизайнер в нашей выборке, благодаря разнообразия речи которого, представители этой профессии и попали в топ-3 этого распределения. Кстати, если вы еще не знакомы с нашим <a href=\"https:\/\/habr.com\/ru\/post\/596035\/\">анализом телеграм-канала Лебедева<\/a>, то мы не понимаем, чего же вы ждете! Несмотря на то что генератор постов Артемия Лебедева сейчас выключен, исследование его телеграм-канала все равно заслуживает вашего внимания.<\/p>\n<h2>Ограничения анализа<\/h2>\n<p>Стоит отметить, что в нашем небольшом исследовании есть два недостатка:<\/p>\n<ol start=\"1\">\n<li>Как уже говорилось ранее, мы не смогли отделить слова гостей Дудя от речи Юрия, который и сам зачастую не брезгует использовать нецензурные выражения. Однако, задача интервьюера — подстроиться под стиль речи гостя, поэтому, скорее всего, результаты бы не сильно изменились.<\/li>\n<li>В автосгенерированных субтитрах нам встретилось некое подобие цензуры — некоторые слова были заменены на ‘[ __ ]’. Тут можно выделить несколько интересных моментов:\n<ul>\n  <li>действительно некоторые матерные слова были зацензурены (по большей части слово “бл**ь”);<\/li>\n  <li>остальные матерные слова остались нетронутыми;<\/li>\n  <li>под чистку попали некоторые другие грубые слова, при этом не являющиеся матерными (“мудак”, “гавно”).<\/li>\n<\/ul>\n<\/li>\n<\/ol>\n<p>Продемонстрируем наглядно на примере следующего диалога:<br \/>\n<i>Дудь: Почему твои треки такое гавно?<\/i><br \/>\n<i>Гнойный: Мои треки ох**тельные, Юра, просто ты любишь гавно.<\/i><\/p>\n<p><img src=\"http:\/\/test.leftjoin.ru\/pictures\/image.png\" border=\"0\" width=\"100%\" height=\"100%\"><\/a><\/p>\n<p>Такие замены встречались в субтитрах роликов с людьми, которые не употребляли нецензурные выражения в своей речи (по крайней мере на протяжении интервью). Однозначное решение, что же делать с ‘[ __ ]’, мы не смогли принять, поэтому для некоторых гостей какая-то часть матерных слов была, увы, не подсчитана.<\/p>\n<h2>Работа с Word2vec<\/h2>\n<p>После статистического анализа интервью мы перешли к определению их контекста. Для этого мы, как и раньше, воспользовались моделью Word2vec. Она основана на нейронной сети и позволяет представлять слова в виде векторов с учетом семантической составляющей. Косинусная мера семантически схожих слов будет стремиться к 1, а у двух слов, не имеющих ничего общего по смыслу, она близка к 0. Модель можно обучать самостоятельно на подготовленном корпусе текстов, но мы решили взять готовую — от <a href=\"https:\/\/rusvectores.org\/ru\/\">RusVectores<\/a>.  Для ее использования нам понадобилась библиотека <a href=\"https:\/\/radimrehurek.com\/gensim\/\">gensim<\/a>.<br \/>\nМы рассчитали векторы-представления для каждой профессиональной группы. Наверное, можно ожидать, что режиссёры обсуждали кино и все, что с ним связано, а музыканты — музыку. Поэтому для каждого рода деятельности мы получили список слов, описывающих тематику текстов соответствующих роликов. Также мы раскрасили ячейки в зависимости от того, насколько каждое полученное слово было близко к текстам соответствующей категории гостей.<\/p>\n<p><img src=\"http:\/\/test.leftjoin.ru\/pictures\/-10-----.png\" border=\"0\" width=\"120%\" height=\"120%\"><\/a><\/p>\n<p>Можно сказать, что в целом каждая профессиональная категория описывается вполне соответствующими терминами. Конечно, некоторые слова могут показаться спорными. К примеру, на первом месте для рэперов стоит слово “джазовый”, хотя ни с 1 представителем хип-хоп течения речь о джазе не заходила. Тем не менее модель посчитала, что это слово достаточно близко к общему смыслу интервью людей, относящихся к этой категории (видимо, за счет непосредственного отношения рэперов к музыке).<\/p>\n<h2>P.S. Мистическое число 25.000000<\/h2>\n<p>Как мы уже говорили, среди скачанных субтитров некоторые были написаны вручную. Интересно, что все они начинаются с числа 25.000000, причем оно нигде не озвучивается.<\/p>\n<p><img src=\"http:\/\/test.leftjoin.ru\/pictures\/25.000000--1.png\" border=\"0\" width=\"50%\" height=\"100%\"><\/a><\/p>\n<p>Что же это за мистическое число? Если уйти в конспирологию, то можно вспомнить про 25-й кадр. К сожалению, нам об этом ничего неизвестно, мы просто оставим это как пищу для размышлений…<\/p>\n<p><img src=\"http:\/\/test.leftjoin.ru\/pictures\/25.000000-.png\" border=\"0\" width=\"100%\" height=\"100%\"><\/a><\/p>\n",
            "date_published": "2022-06-02T11:09:33+03:00",
            "date_modified": "2022-06-02T13:21:08+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/--1.png",
            "_date_published_rfc2822": "Thu, 02 Jun 2022 11:09:33 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "136",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/--1.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/---,-,-.png.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/----.png.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/All-wordclouds.png.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/---.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/----1.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/-_--.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/-----.png.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/-.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/-10-------.png.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/FSgosl8XwAAmtpw.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/-------.png.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/image.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/-10-----.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/25.000000--1.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/25.000000-.png"
                ]
            }
        },
        {
            "id": "130",
            "url": "http:\/\/test.leftjoin.ru\/",
            "title": "Десять советов, чтобы писать SQL-код, который приятно читать и использовать",
            "content_html": "<p><img src=\"http:\/\/test.leftjoin.ru\/pictures\/1*NGniUKoit2YaJY08brT01g.png\" border=\"0\" width=\"100%\" height=\"100%\"><\/a><\/p>\n<p class=\"note\">Перевод статьи <a href=\"https:\/\/towardsdatascience.com\/10-best-practices-to-write-readable-and-maintainable-sql-code-427f6bb98208\">”10 Best Practices to Write Readable and Maintainable SQL Code”<\/a> автора <a href=\"https:\/\/davidjmartins.medium.com\/?source=post_page-----427f6bb98208-----------------------------------\">David Martins<\/a><\/p>\n<h2>Как писать SQL-запросы, которые ваша команда сможет легко читать и использовать?<\/h2>\n<p>Без хорошей культуры написания SQL-запросы очень легко становятся запутанными. У каждого члена команды могут быть свои привычки написания запросов на SQL и вы очень быстро можете получить запутанный код, который будет понятен лишь одному человеку, а все остальные не смогут его использовать.<\/p>\n<p>Думаю, вы осознаёте важность наличия общих правил по написанию SQL-запросов в команде. Эта статья может стать отличным руководством для формирования таких правил!<\/p>\n<h3><b>1. Используйте верхний регистр для ключевых слов<\/b><\/h3>\n<p>Начнем с базовых вещей: используйте заглавные буквы для ключевых слов SQL и строчные буквы для обозначения таблиц и столбцов. Также рекомендуется использовать прописные буквы для функций SQL (FIRST_VALUE(), DATE_TRUNC() и т. д.), хотя это спорно.<\/p>\n<p><b>Избегать:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>select id, name from company.customers<\/code><\/pre><p><b>Предпочтительно:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT id, name FROM company.customers<\/code><\/pre><h3><b>2. Используйте Snake Case для схем, таблиц, столбцов<\/b><\/h3>\n<p>В разных языках программирования есть разные оптимальные стили написания названий из нескольких слов: camelCase, PascalCase, kebab-case, and snake_case являются наиболее распространенными.<\/p>\n<p>Если говорить про SQL, Snake Case (иногда называемый регистром подчеркивания) является наиболее широко используемым правилом. Чтобы писать в стиле snake_case, нужно заменить пробелы знаками подчеркивания. При этом все слова пишутся строчными буквами.<\/p>\n<p><b>Избегать:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT Customers.id, \r\n       Customers.name, \r\n       COUNT(WebVisit.id) as nbVisit\r\nFROM COMPANY.Customers\r\nJOIN COMPANY.WebVisit ON Customers.id = WebVisit.customerId\r\n\r\nWHERE Customers.age &lt;= 30\r\nGROUP BY Customers.id, Customers.name<\/code><\/pre><p><b>Предпочтительно:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT customers.id, \r\n       customers.name, \r\n       COUNT(web_visit.id) as nb_visit\r\nFROM company.customers\r\nJOIN company.web_visit ON customers.id = web_visit.customer_id\r\n\r\nWHERE customers.age &lt;= 30\r\nGROUP BY customers.id, customers.name<\/code><\/pre><p>Хотя некоторым нравится использовать разные стили составных названий (чтобы различать схемы, таблицы и столбцы), я бы рекомендовал придерживаться Snake Case.<\/p>\n<h3><b>3. Используйте псевдонимы, чтобы сделать запрос понятнее<\/b><\/h3>\n<p>Хорошо известно, что псевдонимы — это удобный способ для переименования таблиц или столбцов, чтобы добавить точность смысловой нагрузкой. Не стесняйтесь давать псевдонимы таблицам и столбцам, чтобы название лучше описывало процесс в а функциях.<\/p>\n<p><b>Избегать:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT customers.id, \r\n       customers.name, \r\n       customers.context_col1,\r\n       nested.f0_\r\nFROM company.customers\r\nJOIN (\r\n          SELECT customer_id,\r\n                 MIN(date)\r\n          FROM company.purchases\r\n          GROUP BY customer_id\r\n      ) ON customer_id = customers.id<\/code><\/pre><p><b>Предпочтительно:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT customers.id, \r\n       customers.name, \r\n       customers.context_col1 as ip_address,\r\n       first_purchase.date    as first_purchase_date\r\nFROM company.customers\r\nJOIN (\r\n          SELECT customer_id,\r\n                 MIN(date) as date\r\n          FROM company.purchases\r\n          GROUP BY customer_id\r\n      ) AS first_purchase \r\n        ON first_purchase.customer_id = customers.id<\/code><\/pre><p>Я обычно использую для столбцов строчные буквы “as”, а для таблиц — прописные “AS”.<\/p>\n<h3><b>4. Форматирование: осторожно используйте отступы и пробелы<\/b><\/h3>\n<p>Это базовый принцип. Один из ключевых элементов, чтобы все функции в вашем запросе были четко видны. Если вы знаете Python (в нем без грамотных отступов код не будет работать), то примените этот навык здесь.<\/p>\n<p>Используйте пробелы после ключевого слова и при обозначении подзапроса или производной таблицы.<\/p>\n<p><b>Избегать:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT customers.id, customers.name, customers.age, customers.gender, customers.salary, first_purchase.date\r\nFROM company.customers\r\nLEFT JOIN ( SELECT customer_id, MIN(date) as date FROM company.purchases GROUP BY customer_id ) AS first_purchase \r\nON first_purchase.customer_id = customers.id \r\nWHERE customers.age&lt;=30<\/code><\/pre><p><b>Предпочтительно:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT customers.id, \r\n       customers.name, \r\n       customers.age, \r\n       customers.gender, \r\n       customers.salary,\r\n       first_purchase.date\r\nFROM company.customers\r\nLEFT JOIN (\r\n              SELECT customer_id,\r\n                     MIN(date) as date \r\n              FROM company.purchases\r\n              GROUP BY customer_id\r\n          ) AS first_purchase \r\n            ON first_purchase.customer_id = customers.id\r\nWHERE customers.age &lt;= 30<\/code><\/pre><p>Обратите внимание, как использованы пробелы в условии “where”.<\/p>\n<p><b>Избегать:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT id WHERE customers.age&lt;=30<\/code><\/pre><p><b>Предпочтительно:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT id WHERE customers.age &lt;= 30<\/code><\/pre><h3><b>5. Избегайте Select<\/b>*<\/h3>\n<p>Не забывайте об этом правиле: вам следует точно указывать, какие элементы таблицы вы хотите выбрать и забыть про Select*!<\/p>\n<p>Select* делает ваш запрос неясным, поскольку он скрывает намерения, стоящие за запросом. Кроме того, помните, что ваши таблицы могут эволюционировать и влиять на Select*. Вот почему я не большой поклонник инструкции EXCEPT().<\/p>\n<p><b>Избегать:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT * EXCEPT(id) FROM company.customers<\/code><\/pre><p><b>Предпочтительно:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT name,\r\n       age,\r\n       salary\r\nFROM company.customers<\/code><\/pre><h3><b>6. Используйте синтаксис JOIN ANSI-92<\/b><\/h3>\n<p>…для соединения таблиц, вместо условия WHERE. Несмотря на то, что для соединения таблиц можно использовать как условие WHERE, так и условие JOIN, лучше использовать синтаксис JOIN\/ANSI-92.<\/p>\n<p>Хотя с точки зрения производительности разницы нет, условие JOIN отделяет логику отношения от фильтров.<\/p>\n<p><b>Избегать:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT customers.id, \r\n       customers.name, \r\n       COUNT(transactions.id) as nb_transaction\r\nFROM company.customers, company.transactions\r\nWHERE customers.id = transactions.customer_id\r\n      AND customers.age &lt;= 30\r\nGROUP BY customers.id, customers.name<\/code><\/pre><p><b>Предпочтительно:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT customers.id, \r\n       customers.name, \r\n       COUNT(transactions.id) as nb_transaction\r\nFROM company.customers\r\nJOIN company.transactions ON customers.id = transactions.customer_id\r\nWHERE customers.age &lt;= 30\r\nGROUP BY customers.id, customers.name<\/code><\/pre><p>Синтаксис, основанный на условии “Where”, также известный как ANSI-89, старше нового ANSI-92, поэтому он всё ещё очень распространен. Сегодня большинство разработчиков и аналитиков данных используют синтаксис JOIN.<\/p>\n<h3><b>7. Используйте обобщённое табличное выражение (Common Table Expression — CTE)<\/b><\/h3>\n<p>CTE позволяет создать запрос, результат которого существует временно и может использоваться в более крупном запросе. CTE доступны в большинстве современных баз данных.<\/p>\n<p>Он работает как производная таблица с двумя преимуществами:<\/p>\n<ul>\n<li>Использование CTE улучшает читабельность вашего запроса.<\/li>\n<li>CTE определяется один раз, после чего на него можно ссылаться многократно.<\/li>\n<\/ul>\n<p>CTE объявляется с помощью инструкции <b>WITH … AS<\/b>:<\/p>\n<pre class=\"e2-text-code\"><code>WITH my_cte AS\r\n(\r\n  SELECT col1, col2 FROM table\r\n)\r\nSELECT * FROM my_cte<\/code><\/pre><p><b>Избегать:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>SELECT customers.id, \r\n       customers.name, \r\n       customers.age, \r\n       customers.gender, \r\n       customers.salary,\r\n       persona_salary.avg_salary as persona_avg_salary,\r\n       first_purchase.date\r\nFROM company.customers\r\nJOIN (\r\n          SELECT customer_id,\r\n                 MIN(date) as date \r\n          FROM company.purchases\r\n          GROUP BY customer_id\r\n      ) AS first_purchase \r\n        ON first_purchase.customer_id = customers.id\r\nJOIN (\r\n          SELECT age,\r\n             gender,\r\n             AVG(salary) as avg_salary\r\n         FROM company.customers\r\n         GROUP BY age, gender\r\n      ) AS persona_salary \r\n        ON persona_salary.age = customers.age\r\n           AND persona_salary.gender = customers.gender\r\nWHERE customers.age &lt;= 30<\/code><\/pre><p><b>Предпочтительно:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>WITH first_purchase AS\r\n(\r\n   SELECT customer_id,\r\n          MIN(date) as date \r\n   FROM company.purchases\r\n   GROUP BY customer_id\r\n),\r\npersona_salary AS\r\n(\r\n   SELECT age,\r\n          gender,\r\n          AVG(salary) as avg_salary\r\n   FROM company.customers\r\n   GROUP BY age, gender\r\n)\r\nSELECT customers.id, \r\n       customers.name, \r\n       customers.age, \r\n       customers.gender, \r\n       customers.salary,\r\n       persona_salary.avg_salary as persona_avg_salary,\r\n       first_purchase.date\r\nFROM company.customers\r\nJOIN first_purchase ON first_purchase.customer_id = customers.id\r\nJOIN persona_salary ON persona_salary.age = customers.age\r\n                       AND persona_salary.gender = customers.gender\r\nWHERE customers.age &lt;= 30<\/code><\/pre><h3><b>8. Иногда стоит разделить запрос на несколько<\/b><\/h3>\n<p>...но не увлекайтесь. Давайте разберем на примере.<\/p>\n<p>Я часто использую AirFlow для выполнения SQL-запросов в BigQuery, преобразования данных и подготовки визуализации данных. В нем есть оркестратор рабочих процессов (Airflow), который выполняет запросы в определенном порядке. В некоторых ситуациях лучше разбивать сложные запросы на несколько более мелких.<\/p>\n<p><b>Вместо:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>CREATE TABLE customers_infos AS\r\nSELECT customers.id,\r\n       customers.salary,\r\n       traffic_info.weeks_since_last_visit,\r\n       category_info.most_visited_category_id,\r\n       purchase_info.highest_purchase_value\r\nFROM company.customers\r\nLEFT JOIN ([..]) AS traffic_info\r\nLEFT JOIN ([..]) AS category_info\r\nLEFT JOIN ([..]) AS purchase_info<\/code><\/pre><p><b>Вы могли бы использовать:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>## STEP1: Create initial table\r\nCREATE TABLE public.customers_infos AS\r\nSELECT customers.id,\r\n       customers.salary,\r\n       0 as weeks_since_last_visit,\r\n       0 as most_visited_category_id,\r\n       0 as highest_purchase_value\r\nFROM company.customers\r\n## STEP2: Update traffic infos\r\nUPDATE public.customers_infos\r\nSET weeks_since_last_visit = DATE_DIFF(CURRENT_DATE,\r\n                                       last_visit.date, WEEK)\r\nFROM (\r\n         SELECT customer_id, max(visit_date) as date\r\n         FROM web.traffic_info\r\n         GROUP BY customer_id\r\n     ) AS last_visit\r\nWHERE last_visit.customer_id = customers_infos.id\r\n## STEP3: Update category infos\r\nUPDATE public.customers_infos\r\nSET most_visited_category_id = [...]\r\nWHERE [...]\r\n## STEP4: Update purchase infos\r\nUPDATE public.customers_infos\r\nSET highest_purchase_value = [...]\r\nWHERE [...]<\/code><\/pre><p><b>ПРЕДУПРЕЖДЕНИЕ!<\/b> <br \/>\nНесмотря на то, что этот метод отлично подходит для упрощения сложных запросов, вместе с повышением читабельности кода, вы можете здорово понизить его производительность.<\/p>\n<p>Это особенно важно, если вы работаете с базой данных OLAP или любой колоночной базой данных, оптимизированной для агрегационных и аналитических запросов (SELECT, AVG, MIN, MAX, …), но менее производительной, когда речь идет о транзакциях (UPDATE).<\/p>\n<p>Несмотря на это в некоторых случаях это может улучшить производительность работы с базой данных. Даже в современной базе данных, ориентированной на колонки, слишком большое количество JOIN’ов приведет к проблемам с памятью или производительностью. В таких ситуациях разделение вашего запроса обычно помогает улучшить производительность и оптимизировать используемую память.<\/p>\n<p>Кроме того, не стоит забывать, что вам нужен инструмент или оркестратор для выполнения ваших запросов в определенном порядке.<\/p>\n<h3><b>9. Осмысленные названия, основанные на ваших внутренних правилах<\/b><\/h3>\n<p>Правильно называть схемы и таблицы сложно. Какие варианты возможных имён использовать — вопрос дискуссионный, но задача выбора единых правил присвоения имён — это не сложно. Вы должны определить <b>свои<\/b> правила и использовать их всей командой.<\/p>\n<p>“В компьютерных науках есть только две сложные проблемы: аннулирование кэша и придумывание названий.” — Фил Карлтон<\/p>\n<p>Вот примеры правил, которые я использую:<\/p>\n<h4><b>Схемы<\/b><\/h4>\n<p>Если вы работаете с аналитической базой данных, которая служит нескольким целям, хорошей практикой является организация таблиц в выразительные схемы.<\/p>\n<p>В нашей базе данных BigQuery у нас есть одна схема для каждого источника данных. Что еще более важно, мы выводим результаты в разных схемах в зависимости от их назначения.<\/p>\n<ul>\n<li>Любая таблица, которая будет доступна для стороннего инструмента, находится в *<b>общедоступной<\/b>* схеме. Инструменты визуализации данных, такие как DataStudio или Tableau, получают данные из неё.<\/li>\n<li>Поскольку мы используем машинное обучение с BQML, ****<b>у нас есть специальная схема <\/b>*machine_learning.***<\/li>\n<\/ul>\n<h4>Таблицы<\/h4>\n<p>Сами таблицы должны называться в соответствии с правилами. В Agorapulse у нас есть несколько дашбордов для визуализации данных, каждый из которых имеет свое назначение: дашборд управления маркетингом, дашборд управления продуктом, дашборд управления для руководителей и многие другие.<\/p>\n<p>Каждая таблица в нашей общедоступной схеме имеет префикс имени дашборда. Выглядит это примерно так:<\/p>\n<pre class=\"e2-text-code\"><code>product_inbox_usage\r\nproduct_addon_competitor_stats\r\nmarketing_acquisition_agencies\r\nExecutive_funnel_overview<\/code><\/pre><p>При командной работе стоит уделить время определению общих правил. Когда вы придумываете название новой таблицы, то не используйте быстрое и заезженное имя, которое вы «измените позже» — вы наверняка этого не сделаете.<\/p>\n<p>Не стесняйтесь использовать эти примеры для создания своих правил.<\/p>\n<h3><b>10. Пишите полезные комментарии… но не слишком много<\/b><\/h3>\n<p class=\"note\"><i>Примечание переводчика:<\/i> Автор пишет, что “хорошо написанный код с правильными названиями не нуждается в комментариях” и иронично продемонстрировал детально расписанный код вообще без комментариев. Мы все-таки за то, чтобы необходимые пояснения в коде были, ведь это сильно ускоряет понимание сути запроса.<\/p>\n<p>Я согласен с тезисом, что хорошо написанный код с правильными названиями не нуждается в комментариях. Тот, кто читает ваш код, должен понимать логику и замысел еще до того, как появится результат работы кода.<\/p>\n<p>Тем не менее, комментарии могут быть полезны в некоторых ситуациях. Но перебарщивать с ними не стоит!<\/p>\n<p><b>Избегать:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>WITH fp AS\r\n(\r\n   SELECT c_id,               # customer id\r\n          MIN(date) as dt     # date of first purchase\r\n   FROM company.purchases\r\n   GROUP BY c_id\r\n),\r\nps AS\r\n(\r\n   SELECT age,\r\n          gender,\r\n          AVG(salary) as avg\r\n   FROM company.customers\r\n   GROUP BY age, gender\r\n)\r\nSELECT customers.id, \r\n       ct.name, \r\n       ct.c_age,            # customer age\r\n       ct.gender,\r\n       ct.salary,\r\n       ps.avg,              # average salary of a similar persona\r\n       fp.dt                # date of first purchase for this client\r\nFROM company.customers ct\r\n# join the first purchase on client id\r\nJOIN fp ON c_id = ct.id\r\n# match persona based on same age and genre\r\nJOIN ps ON ps.age = c_age\r\n           AND ps.gender = ct.gender\r\nWHERE c_age &lt;= 30<\/code><\/pre><p><b>Предпочтительно:<\/b><\/p>\n<pre class=\"e2-text-code\"><code>WITH first_purchase AS\r\n(\r\n   SELECT customer_id,\r\n          MIN(date) as date \r\n   FROM company.purchases\r\n   GROUP BY customer_id\r\n),\r\npersona_salary AS\r\n(\r\n   SELECT age,\r\n          gender,\r\n          AVG(salary) as avg_salary\r\n   FROM company.customers\r\n   GROUP BY age, gender\r\n)\r\nSELECT customers.id, \r\n       customers.name, \r\n       customers.age, \r\n       customers.gender, \r\n       customers.salary,\r\n       persona_salary.avg_salary as persona_avg_salary,\r\n       first_purchase.date\r\nFROM company.customers\r\nJOIN first_purchase ON first_purchase.customer_id = customers.id\r\nJOIN persona_salary ON persona_salary.age = customers.age\r\n                       AND persona_salary.gender = customers.gender\r\nWHERE customers.age &lt;= 30<\/code><\/pre><h2><b>Вывод<\/b><\/h2>\n<p>SQL великолепен. Это одна из основ анализа данных, науки о данных, инжиниринга данных и даже разработки программного обеспечения: неправильный код не простит ошибки. Его гибкость является силой, но может быть ловушкой.<\/p>\n<p>Сначала вы можете этого не осознавать, особенно если с кодом работаете только вы. Но когда вы работаете в команде или если кто-то должен будет продолжать вашу работу, SQL-код написанный без соблюдения этих правил будет ночным кошмаром аналитика.<\/p>\n<p>В этой статье я обобщил наиболее распространенные рекомендации по написанию SQL-запросов. Конечно, некоторые из них дискуссионные или основаны на личном мнении — вы можете черпать отсюда вдохновение и создавать собственные правила со своей командой.<\/p>\n<p>Я надеюсь, что эта информация поможет вам вывести качество SQL на новый уровень!<\/p>\n",
            "date_published": "2022-02-22T16:24:18+03:00",
            "date_modified": "2022-02-22T17:11:30+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/1*NGniUKoit2YaJY08brT01g.png",
            "_date_published_rfc2822": "Tue, 22 Feb 2022 16:24:18 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "130",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/1*NGniUKoit2YaJY08brT01g.png"
                ]
            }
        },
        {
            "id": "120",
            "url": "http:\/\/test.leftjoin.ru\/all\/analytical-telegram-channels-graph\/",
            "title": "Граф телеграм-каналов по теме аналитики",
            "content_html": "<p><a href=\"http:\/\/test.leftjoin.ru\/files\/analytics-graph.html\" style=\"text-decoration:none; border:0\"><img src=\"http:\/\/test.leftjoin.ru\/pictures\/graph.png\" border=\"0\" width=\"100%\" height=\"150%\"><\/a><\/p>\n<p>Авторы самых разных блогов в телеграме часто публикуют подборки любимых каналов, которыми они хотят поделиться со своей аудиторией. Идея, конечно, не новая, но я решил не просто составить рейтинг интересных аналитических телеграм-блогов, а решить эту задачу аналитически.<\/p>\n<p>В рамках текущего курса моей учебы, я изучаю много современных подходов к анализу и визуализации данных. В самом начале курса было разминочное упражнение: объектно-ориентированное программирование на Python для сбора и итеративного построения графа с <a href=\"https:\/\/www.themoviedb.org\/documentation\/api\">TMDB API<\/a>. В задаче этот метод применяется для построения графа связи актеров, где связь — игра в одном и том же фильме. Но я решил, что можно применить его и к другой задаче: построению графа связей аналитического сообщества.<\/p>\n<p>Поскольку последнее время мой временной ресурс особенно ограничен, а аналогичную задачу для курса я уже выполнил, то я решил передать эти знания кому-то еще, кто интересуется аналитикой. К счастью, в этот момент, ко мне в личку постучался кандидат на вакансию младшего аналитика данных — Андрей. Он сейчас находится в процессе постижения всех тонкостей аналитики, поэтому мы договорились на стажировку, в рамках которой Андрей спарсил данные с telegram-каналов.<\/p>\n<p>Основной задачей Андрея был сбор всех текстов с телеграм-канала Интернет-аналитика, выделение каналов, на которые ссылался Алексей Никушин, сбор текстов из этих телеграм-каналов и ссылок на этих каналах. Под “ссылкой” подразумевается любое упоминание канала: через @, через ссылку или репостом. В результате парсинга, у Андрея получилось два файла: nodes и edges.<br \/>\nТеперь я представлю вам <a href=\"http:\/\/test.leftjoin.ru\/files\/analytics-graph.html\">граф, который получился у меня на основе этих данных<\/a> и прокомментирую результаты.<\/p>\n<p>Пользуясь случаем, хочу выразить мое почтение команде karpov.courses, поскольку у Андрея отличное знание языка Python!<\/p>\n<p>В результате топ-10 каналов по показателю degree (количество связей) выглядит так:<\/p>\n<ol start=\"1\">\n<li><a href=\"https:\/\/t.me\/internetanalytics\">Интернет-аналитика<\/a><\/li>\n<li><a href=\"https:\/\/t.me\/revealthedata\">Reveal The Data<\/a><\/li>\n<li><a href=\"https:\/\/t.me\/rockyourdata\">Инжиниринг Данных<\/a><\/li>\n<li><a href=\"https:\/\/t.me\/data_events\">Data Events<\/a><\/li>\n<li><a href=\"https:\/\/t.me\/datalytx\">Datalytics<\/a><\/li>\n<li><a href=\"https:\/\/t.me\/chartomojka\">Чартомойка<\/a><\/li>\n<li><a href=\"https:\/\/t.me\/leftjoin\">LEFT JOIN<\/a><\/li>\n<li><a href=\"https:\/\/t.me\/epicgrowth_chat\">Epic Growth<\/a><\/li>\n<li><a href=\"https:\/\/t.me\/rtdlinks\">RTD: ссылки и репосты<\/a><\/li>\n<li><a href=\"https:\/\/t.me\/dashboardets\">Дашбордец<\/a><\/li>\n<\/ol>\n<p>По-моему, получилось супер-круто и визуально интересно, а Андрей — большой молодец! Кстати, он тоже начал свой канал <a href=\"https:\/\/t.me\/eto_analytica\">”Это разве аналитика?”<\/a>, где публикуются новости аналитики.<\/p>\n<p>Забегая вперед: у этой задачи имеется продолжение. С помощью Марковской цепи мы смоделировали в каком канале окажется пользователь, если будет переходить итеративно по всем упоминаниям в каналах. Получилось очень интересно, но об этом мы расскажем в следующий раз!<\/p>\n",
            "date_published": "2021-09-27T17:47:31+03:00",
            "date_modified": "2021-09-27T17:43:48+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/graph.png",
            "_date_published_rfc2822": "Mon, 27 Sep 2021 17:47:31 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "120",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/graph.png"
                ]
            }
        },
        {
            "id": "119",
            "url": "http:\/\/test.leftjoin.ru\/all\/principy-postroeniya-bubble-charts-ploschad-vs-radius\/",
            "title": "Принципы построения bubble-charts: площадь VS радиус",
            "content_html": "<p>Такой навык как визуализация данных применяется в любой отрасли, где присутствуют данные, ведь таблицы хороши лишь для хранения информации. Когда есть необходимость презентовать данные, точнее определенные выводы, полученные на их основе — данные необходимо представить на графиках подходящего типа. И тут перед вами встает две задачи: первая — правильно подобрать тип графика, вторая — правдоподобно отразить результаты на диаграмме. Сегодня мы расскажем вам об одной ошибке, которую иногда допускают дизайнеры при визуализации данных на bubble-charts и о том, как эту ошибку можно избежать.<\/p>\n<h2>Суть построения bubble-чарта<\/h2>\n<p>Немного скучной теории перед тем, как мы приступим к анализу данных. Bubble-chart — удобный способ показать три параметра наблюдения без построения трехмерной модели. По привычным осям X и Y указываются значения двух параметров, а третий показан размером круга, который соответствует каждому наблюдению. Именно это позволяет избежать необходимости построения сложного 3D графика, то есть любой, кто видит bubble-chart, гораздо быстрее сможет сделать выводы о данных изображенных на одной плоскости.<\/p>\n<h2>Ошибка, которую может допустить дизайнер, но не аналитик данных<\/h2>\n<p>С метриками, которые отображены на осях графика не возникает никаких вопросов, это привычный способ их визуализации, а вот с размерами возникает некоторая трудность: как грамотно и точно отобразить изменения в значениях переменной, если управление идет не точкой на оси, а размером этой точки?<br \/>\nДело в том, что при построении такого графика без использования аналитических средств, например, в графическом редакторе, автор может нарисовать круги, принимая радиус круга за его размер. На первый взгляд, все кажется абсолютно корректным — чем больше значение переменной, тем больше радиус круга. Однако, в таком случае, площадь круга будет увеличиваться не как линейная, а как степенная функция, ведь S = π × r2. Например, на рисунке ниже показано, что, если увеличить радиус круга в два раза, то площадь увеличится в 4 раза.<\/p>\n<p><details><br \/>\n<summary><span style=\"color:#7ea9b8\">Построение круга в Matplotlib<\/span><\/summary><\/p>\n<pre class=\"e2-text-code\"><code>fig = plt.figure(figsize=(10, 10))\r\nax = fig.add_subplot(1, 1, 1)\r\ns = 4*10e3\r\n\r\n\r\nax.scatter(100, 100, s=s, c='r')\r\nax.scatter(100, 100, s=s\/4 ,c='b')\r\nax.scatter(100, 100, s=10, c='g')\r\nplt.axvline(99, c='black')\r\nplt.axvline(101, c='black')\r\nplt.axvline(98, c='black')\r\nplt.axvline(102, c='black')\r\n\r\n\r\nax.set_xticks(np.arange(95, 106, 1))\r\nax.grid(alpha=1)\r\n\r\nplt.show()<\/code><\/pre><p><\/details><\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/example.png\" width=\"720\" height=\"720\" alt=\"\" \/>\n<\/div>\n<p>Это значит, что график будет выглядеть неправдоподобно, ведь размеры не будут отражать реальное изменение переменной, а человек обращает внимание и сравнивает именно площадь кругов на графике.<\/p>\n<h2>Как построить такой график правильно?<\/h2>\n<p>К счастью, если строить bubble-charts с помощью библиотек Python (Matplotlib и Seaborn), то размер круга будет определяться именно площадью, что абсолютно корректно и грамотно с точки зрения визуализации.<br \/>\nСейчас на примере реальных данных, найденных на Kaggle, покажем, как построить bubble-chart правильно. В данных присутствуют следующие переменные: страна, численность населения, процент грамотного населения. Для того чтобы диаграмма была читаемой, возьмем подвыборку из 10 первых стран после сортировки всех данных по возрастанию ВВП.<\/p>\n<p>Для начала, загрузим все нужные библиотеки:<\/p>\n<pre class=\"e2-text-code\"><code>import pandas as pd\r\nimport matplotlib.pyplot as plt\r\nimport seaborn as sns<\/code><\/pre><p>Затем, загрузим данные, очистим и от всех строк с пропущенными значениями и приведем данные по численности населения стран в миллионы:<\/p>\n<pre class=\"e2-text-code\"><code>data = pd.read_csv('countries of the world.csv', sep = ',')\r\ndata = data.dropna()\r\ndata = data.sort_values(by = 'Population', ascending = False)\r\ndata = data.head(10)\r\ndata['Population'] = data['Population'].apply(lambda x: x\/1000000)<\/code><\/pre><p>Теперь, когда все подготовка завершена, можно построить bubble-chart:<\/p>\n<pre class=\"e2-text-code\"><code>sns.set(style=&quot;darkgrid&quot;)    \r\nfig, ax = plt.subplots(figsize=(10, 10))    \r\ng = sns.scatterplot(data=data, x=&quot;Literacy (%)&quot;, y=&quot;GDP ($ per capita)&quot;, size = &quot;Population&quot;, sizes=(10,1500), alpha=0.5)\r\nplt.xlabel(&quot;Literacy (Percentage of literate citizens)&quot;)\r\nplt.ylabel(&quot;GDP per Capita&quot;)\r\nplt.title('Chart with bubbles as area', fontdict= {'fontsize': 'x-large'})\r\n\r\ndef label_point(x, y, val, ax):\r\n    a = pd.concat({'x': x, 'y': y, 'val': val}, axis=1)\r\n    for i, point in a.iterrows():\r\n        ax.text(point['x'], point['y']+500, str(point['val']))\r\n\r\nlabel_point(data['Literacy (%)'], data['GDP ($ per capita)'], data['Country'], plt.gca()) \r\n\r\nax.legend(loc='upper left', fontsize = 'medium', title = 'Population (in mln)', title_fontsize = 'large', labelspacing = 1)\r\n\r\nplt.show()<\/code><\/pre><div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/_32.png\" width=\"720\" height=\"720\" alt=\"\" \/>\n<\/div>\n<p>На этом графике получилось понятным образом отобразить три метрики: уровень ВВП на душу населения по оси Y, процент грамотного населения по оси X и численность населения — площадью круга.<\/p>\n<p>Мы рекомендуем использовать площадь в качестве переменной, которая отвечает за размер фигуры, если есть необходимость показать несколько переменных на одном графике.<\/p>\n",
            "date_published": "2021-09-22T12:57:54+03:00",
            "date_modified": "2021-09-21T17:48:19+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/example.png",
            "_date_published_rfc2822": "Wed, 22 Sep 2021 12:57:54 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "119",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/example.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/_32.png"
                ]
            }
        },
        {
            "id": "118",
            "url": "http:\/\/test.leftjoin.ru\/all\/mean-vs-median\/",
            "title": "Различия между медианой и средним арифметическим как целевым показателем анализа данных",
            "content_html": "<p>В сегодняшней статье мы бы хотели осветить простую, но в то же время важную тему выбора простой метрики для оценки того или иного датасета. Со средним арифметическим все давным давно знакомы, чуть ли не каждый школьник отлично знает, что нужно просуммировать все имеющиеся значения, поделить на их количество и получить среднее значение. В школьные знания не входят никакие альтернативные варианты, которых, на самом деле, в статистике много — на любой вкус и случай. Однако, в решении исследовательских и маркетинговых задач люди часто берут именно эту метрику за основу. Правомерно ли это или есть более удачный вариант? Давайте разбираться.<\/p>\n<p>Для начала стоит вспомнить определения двух метрик, о которых мы сегодня поговорим.<br \/>\nСреднее  — самый популярный статистический показатель, который используется для измерения центра данных. А что же такое медиана? Медиана — значение, которое разбивает данные, отсортированные по порядку увеличения значений, на две равные части. Это значит, что медиана показывает центральное значение в выборке, если наблюдений нечетное количество и среднее арифметическое двух значений, если количество наблюдений в выборке четно.<\/p>\n<h2>Исследовательские задачи<\/h2>\n<p>Итак, оценка среднего значения выборки — зачастую важна во многих исследовательских вопросах. Например, специалисты, изучающие демографию часто задаются вопросом изменения численности регионов России, чтобы проследить за динамикой и отразить это в отчетностях. Давайте попробуем рассчитать среднюю численность региона России, а также медиану, а затем сравним полученные результаты.<br \/>\nДля начала, нужно найти и загрузить данные, подключив для этого библиотеку pandas.<\/p>\n<pre class=\"e2-text-code\"><code>import pandas as pd\r\ncity = pd.read_csv('city.csv')<\/code><\/pre><p>Затем, нужно посчитать среднее и медиану выборки.<\/p>\n<pre class=\"e2-text-code\"><code>mean_pop = round(city.population_2020.mean(), 0)\r\nmedian_pop = round(city.population_2020.median(), 0)<\/code><\/pre><p>Значения, естественно, получились разными, так как распределение наблюдений в выборке отлично от нормального. Для того, чтобы понять, сильно ли они отличаются, построим график распределения и отметим среднее и медиану.<\/p>\n<pre class=\"e2-text-code\"><code>import matplotlib.pyplot as plt\r\nimport seaborn as sns\r\n\r\nsns.set_palette('rainbow')\r\nfig = plt.figure(figsize = (20, 15))\r\nax = fig.add_subplot(1, 1, 1)\r\ng = sns.histplot(data = city, x= 'population_2020', alpha=0.6, bins = 100, ax=ax)\r\n\r\ng.axvline(mean_pop, linewidth=2, color='r', alpha=0.9, linestyle='--', label = 'Среднее = {:,.0f}'.format(mean_pop).replace(',', ' '))\r\ng.axvline(median_pop, linewidth=2, color='darkgreen', alpha=0.9, linestyle='--', label = 'Медиана = {:,.0f}'.format(median_pop).replace(',', ' '))\r\n\r\nplt.ticklabel_format(axis='x', style='plain')\r\nplt.xlabel(&quot;Численность населения&quot;, fontsize=25)\r\nplt.ylabel(&quot;Количество городов&quot;, fontsize=25)\r\nplt.title(&quot;Распределение численности населения российских городов&quot;, fontsize=25)\r\nplt.legend(fontsize=&quot;xx-large&quot;)\r\nplt.show()<\/code><\/pre><div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/avg_p2.jpg\" width=\"1440\" height=\"1080\" alt=\"\" \/>\n<\/div>\n<p>Также, на этих данных стоит построить боксплот для более точной визуализации основных квантилей распределения, медианы, среднего и выбросов.<\/p>\n<pre class=\"e2-text-code\"><code>fig = plt.figure(figsize = (10, 10))\r\nsns.set_theme(style=&quot;whitegrid&quot;)\r\nsns.set_palette(palette=&quot;pastel&quot;)\r\n\r\nsns.boxplot(y = city['population_2020'], showfliers = False)\r\n\r\nplt.scatter(0, 550100, marker='*', s=100, color = 'black', label = 'Выбросы')\r\nplt.scatter(0, 560200, marker='*', s=100, color = 'black')\r\nplt.scatter(0, 570300, marker='*', s=100, color = 'black')\r\nplt.scatter(0, mean_pop, marker='o', s=100, color = 'red', edgecolors = 'black', label = 'Среднее')\r\nplt.legend()\r\n\r\nplt.ylabel(&quot;Численность населения&quot;, fontsize=15)\r\nplt.ticklabel_format(axis='y', style='plain')\r\nplt.title(&quot;Боксплот численности населения&quot;, fontsize=15)\r\nplt.show()<\/code><\/pre><div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/bp_city.jpg\" width=\"720\" height=\"720\" alt=\"\" \/>\n<\/div>\n<p>Из графиков следует, что медиана существенно меньше среднего, а также, ясно, что это следствие наличия больших выбросов — Москвы и Санкт-Петербурга. Поскольку среднее арифметическое — метрика крайне чувствительная к выбросам — при их наличии в выборке опираться на выводы относительно среднего не стоит. Рост или снижение численности населения Москвы может сильно смещать среднюю численность по России, однако это не будет влиять на настоящий общерегиональный тренд.<br \/>\nИспользуя среднее арифметическое мы скажем, что численность типичного (среднего) города в РФ — 268 тысяч человек. Однако, это вводит нас в заблуждение, так как среднее значительно превышает медиану исключительно из-за численности населения Москвы и Санкт-Петербурга. На самом деле, численность типичного российского города существенно меньше (аж в 2 раза!) и составляет 104 тысячи жителей.<\/p>\n<h2>Маркетинговые задачи<\/h2>\n<p>В контексте бизнеса разница между средним арифметическим и медианой также важна, так как использование неверной метрики может серьезно сказаться на результатах проведения акции или затруднить достижение цели. Давайте посмотрим на реальном примере, с какими трудностями может столкнуться предприниматель в ритейле, если неверно выберет целевую метрику.<br \/>\nДля начала, как и в предыдущем примере, загрузим датасет о покупках в супермаркете. Выберем необходимые для анализа столбцы датасета и переименуем их, для упрощения кода в дальнейшем. Поскольку эти данные не так хорошо подготовлены, как предыдущие, необходимо сгруппировать все купленные товары по чекам. В этом случае необходима группировка по двум переменным: по id покупателя и по дате покупки (дата и время определяется моментом закрытия чека, поэтому все покупки в рамках одного чека совпадают по дате). Затем, назовем полученный столбец «total_bill», то есть сумма чека и посчитаем среднее и медиану.<\/p>\n<pre class=\"e2-text-code\"><code>df = pd.read_excel('invoice_data.xlsx')\r\ndf_nes = df[['Номер КПП', 'Сумма', 'Дата продажи']]\r\ndf_nes.columns = ['user','total_price', 'date']\r\ngroupped_df = pd.DataFrame(df_nes.groupby(['user', 'date']).total_price.sum())\r\ngroupped_df.columns = ['total_bill']\r\nmean_bill = groupped_df.total_bill.mean()\r\nmedian_bill = groupped_df.total_bill.median()<\/code><\/pre><p>Теперь, как и в предыдущем примере нужно построить график распределения чеков покупателей и боксплот, а также отметить медиану и среднее арифметическое на каждом из них.<\/p>\n<pre class=\"e2-text-code\"><code>sns.set_palette('rainbow')\r\nfig = plt.figure(figsize = (20, 15))\r\nax = fig.add_subplot(1, 1, 1)\r\nsns.histplot(groupped_df, x = 'total_bill', binwidth=200, alpha=0.6, ax=ax)\r\nplt.xlabel(&quot;Покупки&quot;, fontsize=25)\r\nplt.ylabel(&quot;Суммы чеков&quot;, fontsize=25)\r\nplt.title(&quot;Распределение суммы чеков&quot;, fontsize=25)\r\nplt.axvline(mean_bill, linewidth=2, color='r', alpha=1, linestyle='--', label = 'Среднее = {:.0f}'.format(mean_bill))\r\nplt.axvline(median_bill, linewidth=2, color='darkgreen', alpha=1, linestyle='--', label = 'Медиана = {:.0f}'.format(median_bill))\r\nplt.legend(fontsize=&quot;xx-large&quot;)\r\nplt.show()<\/code><\/pre><div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/avg_invoice_33.jpg\" width=\"1440\" height=\"1080\" alt=\"\" \/>\n<\/div>\n<pre class=\"e2-text-code\"><code>fig = plt.figure(figsize = (10, 10))\r\nsns.set_theme(style=&quot;whitegrid&quot;)\r\nsns.set_palette(palette=&quot;pastel&quot;)\r\n\r\nsns.boxplot(y = groupped_df['total_bill'], showfliers = False)\r\n\r\nplt.scatter(0, 1800, marker='*', s=100, color = 'black', label = 'Выбросы')\r\nplt.scatter(0, 1850, marker='*', s=100, color = 'black')\r\nplt.scatter(0, 1900, marker='*', s=100, color = 'black')\r\nplt.scatter(0, mean_bill, marker='o', s=100, color = 'red', edgecolors = 'black', label = 'Среднее')\r\nplt.legend()\r\n\r\nplt.ticklabel_format(axis='y', style='plain')\r\nplt.ylabel(&quot;Сумма чека&quot;, fontsize=15)\r\nplt.title(&quot;Боксплот суммы чеков&quot;, fontsize=15)\r\nplt.show()<\/code><\/pre><div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/bp_invoice.jpg\" width=\"720\" height=\"720\" alt=\"\" \/>\n<\/div>\n<p>Из графиков следует, что распределение смещено к началу координат (отличное от нормального), а значит медиана и среднее не равны. Медианное значение меньше среднего примерно на 220 рублей.<br \/>\nТеперь представим, что у маркетологов есть задача повысить средний чек покупателя. Маркетолог может решить, что поскольку средний чек равен 601 рублю, то можно предложить следующую акцию: «Всем покупателям, кто совершит покупку на 600 рублей, мы предоставляем скидку 20% на товар за 100 рублей». В целом, резонное предложение, однако, в реальности, средний чек ниже — 378 рублей. То есть большая часть покупателей не заинтересуется в предложении, поскольку их покупка обычно не достигает предложенного порога. Это значит. что они не воспользуются предложением и не получат скидку, а компания не сможет достичь поставленной цели и увеличить прибыль супермаркета. Все дело в том, что исходные предпосылки были ошибочны.<\/p>\n<h2>Выводы<\/h2>\n<p>Как вы уже поняли, среднее арифметическое зачастую показывает более значимый и приятный результат, как для бизнеса, так и для исследовательских задач, ведь руководству всегда выгоднее представить ситуацию со средним чеком или демографической ситуацией в стране лучше, чем она есть на самом деле. Однако, необходимо всегда помнить о недостатках такой метрики, как среднее арифметическое, чтобы уметь грамотно выбрать подходящий аналог для оценки той или иной ситуации.<\/p>\n",
            "date_published": "2021-09-16T21:20:47+03:00",
            "date_modified": "2021-10-27T14:06:01+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/avg_p2.jpg",
            "_date_published_rfc2822": "Thu, 16 Sep 2021 21:20:47 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "118",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/avg_p2.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/bp_city.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/avg_invoice_33.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/bp_invoice.jpg"
                ]
            }
        },
        {
            "id": "115",
            "url": "http:\/\/test.leftjoin.ru\/all\/modeling-ltv-with-sql\/",
            "title": "Моделирование LTV в SQL",
            "content_html": "<p>У большинства игровых и мобильных компаний имеется кривая Retention, ранее мы писали о том, <a href=\"http:\/\/test.leftjoin.ru\/all\/retention-rate\/\">что такое Retention и как его посчитать<\/a>. Вкратце — это метрика, которая позволяет понять насколько хорошо продукт вовлекает пользователей в ежедневное использование. А ещё при помощи Retention и ARPDAU можно посчитать LTV (Lifetime Value), пожизненный доход с одного пользователя. Зная средний доход с пользователя за день и кривую Retention мы можем смоделировать ее и спрогнозировать LTV.<\/p>\n<p class=\"note\">Для материала были взяты данные из одного реального игрового проекта. Нулевой день не отображен для того, чтобы видеть динамику в деталях<\/p>\n<p>В сегодняшнем материале мы подробно разберём, как смоделировать LTV для 180 дней при помощи SQL и просто линейной регрессии.<\/p>\n<h2>Как посчитать LTV?<\/h2>\n<p>В общем случае формула LTV выглядит как ARPDAU умноженное на Lifetime — время жизни пользователя в проекте.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/chart.png\" width=\"223\" height=\"19\" alt=\"\" \/>\n<\/div>\n<p>Посмотрим на классический график Retention:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.54.53.png\" width=\"942\" height=\"413\" alt=\"\" \/>\n<\/div>\n<p>Lifetime — это площадь фигуры под Retention:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.55.13.png\" width=\"923\" height=\"410\" alt=\"\" \/>\n<\/div>\n<p class=\"note\">Откуда взялись интегралы и площади можно подробнее узнать в <a href=\"https:\/\/gdcuffs.com\/ltv-integrals-and-areas\/\">этом материале<\/a><\/p>\n<p>Значит, чтобы посчитать Lifetime, нужно взять интеграл от функции удержания по времени. Формула приобретает следующий вид:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/chart-2.png\" width=\"198\" height=\"28\" alt=\"\" \/>\n<\/div>\n<p>Для описания кривой Retention лучше всего подходит степенная функция a*x^b. Вот как она выглядит в сравнении с кривой Retention:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.58.19.png\" width=\"802\" height=\"403\" alt=\"\" \/>\n<\/div>\n<p>При этом x — номер дня, a и b — параметры функции, которую мы построим при помощи линейной регрессии. Регрессия появилась неслучайно — эту степенную функцию можно привести к виду линейной функции, логарифмируя:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/chart-4.png\" width=\"64\" height=\"18\" alt=\"\" \/>\n<\/div>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/chart-5.png\" width=\"154\" height=\"20\" alt=\"\" \/>\n<\/div>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/chart-6.png\" width=\"156\" height=\"19\" alt=\"\" \/>\n<\/div>\n<p>ln(a) — intercept, b — slope. Остаётся найти эти параметры — в линейной регрессии для этого используют метод наименьших квадратов. Lifetime — кумулятивная сумма прогноза за 180 дней. Посчитав её, остаётся умножить Lifetime на ARPDAU и получим LTV за 180 дней.<\/p>\n<h2>Строим LTV<\/h2>\n<p>Перейдём к практике. Для всех расчётов мы использовали данные одной игровой компании и СУБД PostgreSQL — в ней уже реализованы функции поиска параметров для линейной регрессии. Начнём с построения Retention: соберём общее количество пользователей в период с 1 марта по 1 апреля 2021 года — мы изучаем активность за один месяц:<\/p>\n<pre class=\"e2-text-code\"><code>--общее количество юзеров в когорте\r\nwith cohort as (\r\n    select count(distinct id) as total_users_of_cohort\r\n    from users\r\n    where date(registration) between date '2021-03-01' and date '2021-03-30'\r\n),<\/code><\/pre><p>Теперь посмотрим, как ведут себя эти пользователи в последующие 90 дней:<\/p>\n<pre class=\"e2-text-code\"><code>--количество активных юзеров на 1ый день, 2ой, 3ий и тд. из когорты\r\nactive_users as (\r\n    select date_part('day', activity.date - users.registration) as activity_day, \r\n               count(distinct users.id) as active_users_of_day\r\n    from activity\r\n    join users on activity.user_id = users.id\r\n    where date(registration) between date '2021-03-01' and date '2021-03-30' \r\n    group by 1\r\n    having date_part('day', activity.date - users.registration) between 1 and 90 --берем только первые 90 дней, остальные дни предсказываем.\r\n),<\/code><\/pre><p>Кривая Retention — отношение количества активных пользователей к размеру когорты текущего дня. В нашем случае она выглядит так:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.54.53.png\" width=\"942\" height=\"413\" alt=\"\" \/>\n<\/div>\n<p>По данным кривой посчитаем параметры для линейной регрессии. regr_slope(x, y) — функция для вычисления наклона регрессии, regr_intercept(x, y) — функция для вычисления перехвата по оси Y. Эти функции являются стандартными <a href=\"https:\/\/www.postgresql.org\/docs\/9.4\/functions-aggregate.html\">агрегатными функциями в PostgreSQL<\/a> и для известных X и Y по методу наименьших квадратов.<\/p>\n<p>Вернёмся к нашей формуле — мы получили линейное уравнение, и хотим найти коэффициенты линейной регрессии. Перехват по оси Y и коэффициент наклона можем найти по дефолтным для PostgreSQL функциям. Получается:<\/p>\n<p class=\"note\">Подробнее о том, как работают функции intercept(x, y) и slope(x, y) можно почитать в <a href=\"https:\/\/www.mathsisfun.com\/data\/least-squares-regression.html\">этом мануале<\/a><\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/chart-6.png\" width=\"156\" height=\"19\" alt=\"\" \/>\n<\/div>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/chart-10.png\" width=\"217\" height=\"21\" alt=\"\" \/>\n<\/div>\n<p>Из свойства натурального логарифма следует, что:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/chart-11.png\" width=\"165\" height=\"25\" alt=\"\" \/>\n<\/div>\n<p>Наклон считаем аналогичным образом:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/chart-12.png\" width=\"157\" height=\"21\" alt=\"\" \/>\n<\/div>\n<p>Эти же вычисления запишем в подзапрос для расчёта коэффициентов регрессии:<\/p>\n<pre class=\"e2-text-code\"><code>--рассчитываем коэффициенты регрессии\r\ncoef as (\r\n    select exp(regr_intercept(ln(activity), ln(activity_day))) as a, \r\n                regr_slope(ln(activity), ln(activity_day)) as b\r\n    from(\r\n                select activity_day,\r\n                            active_users_of_day::real \/ total_users_of_cohort as activity\r\n                from active_users \r\n                cross join cohort order by activity_day \r\n            )\r\n),<\/code><\/pre><p>И получим прогноз на 180 дней, подставив параметры в степенную функцию, описанную ранее. Заодно посчитаем Lifetime — кумулятивную сумму спрогнозированных данных. В подзапросе coef мы получим только два числа — параметр наклона и перехвата. Чтобы эти параметры были доступны каждой строке подзапроса lt, делаем cross join к coef:<\/p>\n<pre class=\"e2-text-code\"><code>lt as(\r\n    select generate_series as activity_day,\r\n               active_users_of_day::real\/total_users_of_cohort as real_data,\r\n               a*power(generate_series,b) as pred_data, \t \r\n               sum(a*power(generate_series,b)) over(order by generate_series) as cumulative_lt\r\n    from generate_series(1,180,1)\r\n    cross join coef\r\n    join active_users on generate_series = activity_day::int\r\n),<\/code><\/pre><p>Сравним прогноз на 180 дней с Retention:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.30.59.png\" width=\"936\" height=\"425\" alt=\"\" \/>\n<\/div>\n<p>Наконец, считаем сам LTV — Lifetime, умноженный на ARPDAU. В нашем случае ARPDAU равняется $83.7:<\/p>\n<pre class=\"e2-text-code\"><code>select cumulative_lt as LT,\r\n           cumulative_lt * 83.7 as LTV\r\nfrom lt<\/code><\/pre><p>Наконец, построим график LTV на 180 дней:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.51.05.png\" width=\"852\" height=\"416\" alt=\"\" \/>\n<\/div>\n<p>Весь запрос:<\/p>\n<pre class=\"e2-text-code\"><code>--общее количество юзеров в когорте\r\nwith cohort as (\r\n    select count(*) as total_users_of_cohort\r\n    from users\r\n    where date(registration) between date '2021-03-01' and date '2021-03-30'\r\n),\r\n--количество активных юзеров на 1ый день, 2ой, 3ий и тд. из когорты\r\nactive_users as (\r\n    select date_part('day', activity.date - users.registration) as activity_day, \r\n               count(distinct users.id) as active_users_of_day\r\n    from activity\r\n    join users on activity.user_id = users.id\r\n    where date(registration) between date '2021-03-01' and date '2021-03-30' \r\n    group by 1\r\n    having date_part('day', activity.date - users.registration) between 1 and 90 --берем только первые 90 дней, остальные дни предсказываем.\r\n),\r\n--рассчитываем коэффициенты регрессии\r\ncoef as (\r\n    select exp(regr_intercept(ln(activity), ln(activity_day))) as a, \r\n                regr_slope(ln(activity), ln(activity_day)) as b\r\n    from(\r\n                select activity_day,\r\n                            active_users_of_day::real \/ total_users_of_cohort as activity\r\n                from active_users \r\n                cross join cohort order by activity_day \r\n            )\r\n),\r\nlt as(\r\n    select generate_series as activity_day,\r\n               active_users_of_day::real\/total_users_of_cohort as real_data,\r\n               a*power(generate_series,b) as pred_data, \t \r\n               sum(a*power(generate_series,b)) over(order by generate_series) as cumulative_lt\r\n    from generate_series(1,180,1)\r\n    cross join coef\r\n    join active_users on generate_series = activity_day::int\r\n),\r\nselect cumulative_lt as LT,\r\n            cumulative_lt * 83.7 as LTV\r\nfrom lt<\/code><\/pre>",
            "date_published": "2021-08-16T09:32:37+03:00",
            "date_modified": "2021-08-16T11:03:19+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/chart.png",
            "_date_published_rfc2822": "Mon, 16 Aug 2021 09:32:37 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "115",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/chart.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.55.13.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/chart-2.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.58.19.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/chart-4.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/chart-5.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.54.53.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/chart-6.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/chart-10.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/chart-11.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/chart-12.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.30.59.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-08-13--15.51.05.png"
                ]
            }
        },
        {
            "id": "112",
            "url": "http:\/\/test.leftjoin.ru\/all\/zemfira-tableau\/",
            "title": "Анализ альбомов Земфиры: дашборд в Tableau",
            "content_html": "<p><a href=\"http:\/\/test.leftjoin.ru\/tableau\/zemfira.html\" style=\"text-decoration:none; border:0\"><img src=\"http:\/\/test.leftjoin.ru\/pictures\/zemfira.png.jpg\" border=\"0\" width=\"150%\" height=\"150%\"><\/a><\/p>\n<p>В марте мы опубликовали исследование <a href=\"http:\/\/test.leftjoin.ru\/all\/borderline-text-analysis\/\" class=\"nu\">«<u>Python и тексты нового альбома Земфиры: анализируем суть песен<\/u>»<\/a>, в котором при помощи Word2Vec-модели проанализировали близость песен альбома «бордерлайн» и получили самые близкие слова по духу альбома — ими оказались «пламень», «гореть», «тоска», «печаль», «сердце», «солнце» и другие.<\/p>\n<p>Мы продолжили работу над альбомами Земфиры и проанализировали семь из них, а затем результаты собрали в один дашборд и опубликовали его в <a href=\"http:\/\/test.leftjoin.ru\/tableau\/zemfira.html\">Tableau Public<\/a>. Посмотрите, что получилось.<\/p>\n<p>Заглавная страница — общий анализ семи альбомов Земфиры. Переключиться на конкретный альбом можно по нажатию на его иконку внизу страницы. Для каждого альбома представлена матрица семантической близости песен, облако слов и топ схожих слов для альбома.<\/p>\n",
            "date_published": "2021-07-08T13:56:12+03:00",
            "date_modified": "2021-07-08T13:43:32+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/zemfira.png.jpg",
            "_date_published_rfc2822": "Thu, 08 Jul 2021 13:56:12 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "112",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/zemfira.png.jpg"
                ]
            }
        },
        {
            "id": "111",
            "url": "http:\/\/test.leftjoin.ru\/",
            "title": "Анализируем речь в Python: О чем говорят гости youtube-канала вДудь",
            "content_html": "<p>Сегодня при помощи ML мы будем анализировать прямую речь. В качестве данных используем интервью, которые журналист Юрий Дудь берет для своего YouTube-канала.<br \/>\nВыход практически каждого ролика на канале «вДудь» считается событием, а некоторые из этих релизов даже сопровождаются скандалами из-за неосторожных высказываний его гостей.<br \/>\nПосмотрим с помощью Python и лемматизации о чем таком интересном рассказывали герои роликов канала «вДудь».<\/p>\n<p><b>Парсим тексты субтитров<\/b><br \/>\nВ этом проекте мы будем использовать библиотеки, которые обрабатывают тексты, но сначала нам нужно эти тексты добыть. Импортируем API-интерфейс Python <span class=\"inline-code\">youtube_transcript_api<\/span>, который скачивает субтитры из видео на YouTube.<\/p>\n<pre class=\"e2-text-code\"><code>import pandas as pd\r\nimport numpy as np\r\n\r\nfrom youtube_transcript_api import YouTubeTranscriptApi\r\nimport json<\/code><\/pre><p>Предобработаем URL видео для скачивания субтитров. Всего мы собрали 100 роликов с интервью. В некоторых из интервью нет подготовленных субтитров. В файле <span class=\"inline-code\">‘dud.csv’<\/span> заранее подготовлен список гостей канала вДудь с ссылками на их интервью.<\/p>\n<pre class=\"e2-text-code\"><code>def new_url(s):\r\n    return s.replace('watch?v=','').replace('be.com','.be').replace('www.','')\r\n\r\ndef url_to_id(s):\r\n    return s.partition('be\/')[2]\r\n\r\ndf = pd.read_csv('dud.csv')\r\ndf['URL'] = df['URL'].apply(new_url)\r\ndf['video_id'] = df['URL'].apply(url_to_id)\r\ndf = df.set_index(keys='Гость')<\/code><\/pre><p>У нас теперь есть датафрейм, в котором пока только информация о гостях и ссылка на видео. Но это пока.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-06-01--10.49.04.png\" width=\"638\" height=\"407\" alt=\"\" \/>\n<\/div>\n<p>Загрузим в нашу таблицу субтитры интервью. Если субтитры найти не удалось, то выведем на экран имена людей, к интервью с которыми их нет или они отключены (или субтитры есть, но не на русском языке).<\/p>\n<pre class=\"e2-text-code\"><code>texts = []\r\nno_sub = []\r\nfor speaker in df.index:\r\n    video_id = df.loc[speaker,'video_id']\r\n    try:\r\n        data = YouTubeTranscriptApi.get_transcript(video_id, languages=['ru', 'ru'])\r\n        data = ' '.join([words['text'] for words in data])\r\n    except Exception:\r\n        print('Нет Субтитров для: ', speaker)\r\n        no_sub.append(speaker)\r\n        data = &quot;&quot;\r\n    texts.append(data)\r\ndf['text'] = texts\r\ndf.to_csv('df_dud.csv')<\/code><\/pre><p>У девяти из 100 интервьюируемых субтитров не оказалось и нам вернулся такой текст:<\/p>\n<pre class=\"e2-text-code\"><code>Нет Субтитров для:  L'one\r\nНет Субтитров для:  Шнур\r\nНет Субтитров для:  Ресторатор\r\nНет Субтитров для:  Амиран\r\nНет Субтитров для:  Ильич\r\nНет Субтитров для:  Соболев\r\nНет Субтитров для:  Иван Дорн\r\nНет Субтитров для:  Навальный\r\nНет Субтитров для:  Noize MC<\/code><\/pre><p><b>Анализируем тексты<\/b><br \/>\nАнализ текстовой информации сложен в той степени, в какой сложен язык, на котором написан текст. Самый популярный способ решения такой аналитической задачи — стемминг. Стеммингом называют процесс нахождения стема — основы слова. Для стемминга используют библиотеку NLTK (Natural Language Toolkit), которая содержит правила образования стемов.<br \/>\nЭтот метод хорошо работает с английскими словами, но у русского языка слишком сложно устроена морфология образования слов, что повышает вероятность ошибки. Стемминг будет хорошим выбором для анализа строк, содержание которых вы примерно представляете себе (например, когда пользователя просят заполнить форму).<br \/>\nДля нашего кейса лучше выбрать лемматизацию — приведение слова к его словарной форме. Проведя лемматизацию текстовых данных по правилам русского языка мы получим существительные в именительном падеже единственного числа (кошками — кошка), прилагательные в именительном падеже мужского рода (пушистая — пушистый), а глаголы в инфинитиве несовершенного вида (бежит — бежать). В этом проекте мы используем MyStem и Pymorphy. Обе библиотеки представляют собой морфологические анализаторы.<br \/>\nКроме того, поскольку при анализе  мы будем использовать алгоритмы машинного обучения, то нам нужно избавиться от слов, которые часто встречаются, но не несут какой-то ценности для анализа. В противном случае они могут повлиять на работу модели. Список таких стоп-слов возьмем из библиотеки <span class=\"inline-code\">nltk.corpus<\/span>.<br \/>\nМаксимально подробно о подготовке текста к анализу мы рассказывали в материале <a href=\"http:\/\/test.leftjoin.ru\/all\/borderline-text-analysis\/\" class=\"nu\">«<u>Python и тексты нового альбома Земфиры<\/u>»<\/a>. Тут была проведена идентичная работа подготовка текстов, после чего мы посчитали количество уникальных слов (’Unique Words’) и записали, как часто они встречаются в речи собеседников Дудя (‘PPT Unique Words’).<\/p>\n<pre class=\"e2-text-code\"><code>df['Total Words'] = df['text'].apply(number_words)\r\ndf['Unique Words'] = df['text'].apply(set).apply(len)\r\ndf['PPT Unique Words'] = df['Unique Words'] \/ df['Total Words'] * 100\r\ndf['PPT Unique Words'] = df['PPT Unique Words'].apply(lambda x: round(x,2))\r\ndf.to_csv('df_dud.csv')<\/code><\/pre><p><b>Строим облако слов<\/b><br \/>\nАвтоматизируем построение облака слов для каждого гостя Дудя. Таким образом мы узнаем какие слова встречаются в их речи чаще всего. Для визуализации инсталлируем <span class=\"inline-code\">wordcloud<\/span>, а <span class=\"inline-code\">word_tokenize<\/span> подсчитает количество слов, которые будут встречаться чаще всего.<\/p>\n<pre class=\"e2-text-code\"><code>import nltk\r\nfrom wordcloud import WordCloud\r\nimport pandas as pd\r\nimport matplotlib.pyplot as plt\r\nfrom nltk import word_tokenize, ngrams\r\n\r\ndef word_cloud(df, occup=None, general=True):\r\n    if occup:\r\n        df = df[df['Род деятельности'] == occup] \r\n    if general:\r\n        data_source = zip([occup], [' '.join([el for el in df['Prepared Text']])])\r\n        col_count, row_count = 1, 1\r\n    else:\r\n        data_source = zip([el for el in df.index], df['Prepared Text']) \r\n        col_count = max(1, df.shape[0] \/\/ 3)\r\n        row_count = df.shape[0] \/\/ col_count + 1\r\n        \r\n    fig = plt.figure()\r\n    plt.figure(figsize=(10, 10))\r\n    fig.patch.set_facecolor('white')\r\n    plt.subplots_adjust(wspace=0.3, hspace=0.2)\r\n    i = 1\r\n    for name, text in data_source:\r\n        tokens = word_tokenize(text)\r\n        text_raw = &quot; &quot;.join(tokens)\r\n        wordcloud = WordCloud(colormap='PuBu', background_color='white', contour_width=10).generate(text_raw)\r\n        plt.subplot(row_count, col_count, i, label=name,frame_on=True)\r\n        plt.tick_params(labelsize=10)\r\n        plt.imshow(wordcloud)\r\n        plt.axis(&quot;off&quot;)\r\n        plt.title(name,fontdict={'fontsize':12,'color':'grey'},y=1.0)\r\n        plt.tick_params(labelsize=10)\r\n        i += 1\r\n    plt.savefig(f'.\/word_cloud\/{occup}.png', dpi=900)<\/code><\/pre><p>У нас получились вот такие облака слов по каждому из гостей программы:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-06-02--18.58.17.png\" width=\"1198\" height=\"678\" alt=\"\" \/>\n<\/div>\n<p><b>Работа с Word2vec<\/b><br \/>\nС помощью библиотеки <span class=\"inline-code\">gensim<\/span> вызываем модуль, который должен представить слова в наших текстах как векторы.<\/p>\n<pre class=\"e2-text-code\"><code>import plotly.graph_objects as go\r\nimport plotly.figure_factory as ff\r\nfrom scipy import spatial\r\nimport collections\r\nimport pymorphy2\r\nimport gensim\r\n\r\nmorph = pymorphy2.MorphAnalyzer()<\/code><\/pre><p>Для работы модели используем бинарный файл <span class=\"inline-code\">‘model.bin’<\/span>:<\/p>\n<pre class=\"e2-text-code\"><code>model = gensim.models.KeyedVectors.load_word2vec_format('model.bin', binary=True)<\/code><\/pre><p>Модель <b>Word2Vec<\/b> основана на нейронных сетях и позволяет представлять слова в виде векторов, учитывая семантическую составляющую. Ее мы уже использовали в анализе лирики Земфиры. Косинусная мера семантически схожих слов будет стремиться к 1, а  у двух слов, не имеющих ничего общего по смыслу, она близка к 0.<br \/>\nНапишем функцию, которая будет принимать список слов из наших интервью, распознавать для каждого часть речи, а затем получать и суммировать вектора — так мы сможем находить вектора не для одного слова, а для целых предложений и текстов.<\/p>\n<pre class=\"e2-text-code\"><code>def get_vector(word_list):\r\n    vector = 0\r\n    for word in word_list:\r\n        pos = morph.parse(word)[0].tag.POS\r\n        if pos == 'INFN':\r\n            pos = 'VERB'\r\n        if pos in ['ADJF', 'PRCL', 'ADVB', 'NPRO']:\r\n            pos = 'NOUN'\r\n        if word and pos:\r\n            try:\r\n                word_pos = word + '_' + pos\r\n                this_vector = model.word_vec(word_pos)\r\n                vector += this_vector\r\n            except KeyError:\r\n                continue\r\n    return vector<\/code><\/pre><p>Для каждого интервью находим вектор и собираем соответствующий столбец в датафрейм:<\/p>\n<pre class=\"e2-text-code\"><code>vec_list = []\r\nfor word in df['Prepared Text']:\r\n    vec_list.append(get_vector(word.split()))\r\ndf['Vector'] = vec_list<\/code><\/pre><p>Напишем функцию, который будет подсчитывать N-граммы для каждого гостя:<\/p>\n<pre class=\"e2-text-code\"><code>def get_top_five_ngrams(text, n):\r\n    counter = collections.Counter()\r\n    bigrams = list(ngrams(text, n))\r\n    counter.update(bigrams)\r\n    return counter.most_common()[:10]<\/code><\/pre><p>Построим топ N-грамм в соответствии с группой:<\/p>\n<pre class=\"e2-text-code\"><code>top_words = dict.fromkeys(df.index)\r\nfor person in df.index:\r\n    text = df.loc[person,'Prepared Text']\r\n    n_gram = get_top_five_ngrams(text.split(), 1)\r\n    n_list = []\r\n    for item in n_gram:\r\n        n_list.append(item[0][0])\r\n    top_words[person] = n_list\r\nordered_pesrons = df.index\r\ntop_2_words = []\r\nfor person in ordered_pesrons:\r\n    top_2_words.append(top_words[person])\r\ndf['Top bigramms'] = top_2_words<\/code><\/pre><p>Напишем функцию, которая будет добавлять самые часто встречающиеся слова в речи интервьюируемого:<\/p>\n<pre class=\"e2-text-code\"><code>def top_similar(df, occup=None, agg='Person'):\r\n    if occup:\r\n        df = df[df['Род деятельности'] == occup]\r\n    if agg == 'Person':\r\n        top_words_person = dict.fromkeys(df.index)\r\n        for person in df.index:\r\n            vec = df.loc[person, 'Vector']\r\n            words = model.similar_by_vector(vec, topn=10)\r\n            top_words_person[person] = [el[0].split('_')[0] for el in words]\r\n        df_person_words = pd.DataFrame(columns=[agg,'Top Words'])\r\n    elif agg == 'Total':\r\n        top_words_person = {'Total':0}\r\n        vec = df['Vector'].sum()\r\n        words = model.similar_by_vector(vec, topn=10)\r\n        top_words_person['Total'] = [el[0].split('_')[0] for el in words]\r\n        df_person_words = pd.DataFrame(columns=[agg,'Top Words'])\r\n    \r\n    for k,v in top_words_person.items():\r\n        df_person_words = df_person_words.append({agg:k, 'Top Words':v},ignore_index=True)\r\n    df_person_words = df_person_words.set_index(keys=agg) \r\n    \r\n    return df_person_words<\/code><\/pre><p>Для дальнейшей работы группируем гостей по цеховой принадлежности. Наверное, можно ожидать, что режиссеры будут обсуждать кино и все, что с ним связано, а музыканты — музыку.<\/p>\n<pre class=\"e2-text-code\"><code>df_occup = pd.DataFrame(columns=['Occupation', 'Top Words'])\r\nfor occup in df['Род деятельности'].unique():\r\n    words = top_similar(df, occup=occup, agg='Total')['Top Words'][0]\r\n    df_occup = df_occup.append({'Occupation':occup, 'Top Words’:words},ignore_index=True)\r\n\r\nfor i in range(10):\r\n    df_occup[f'Top {i+1} word'] = df_occup['Top Words'].apply(lambda x: x[i])<\/code><\/pre><p>У нас получится новый датафрейм с топом слов для каждой категории гостей (музыкант, политик, актер и тд).<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-06-02--20.55.04.png\" width=\"1104\" height=\"631\" alt=\"\" \/>\n<\/div>\n<p><b>Анализ риторики гостя<\/b><br \/>\nИспользуя метод <span class=\"inline-code\">similar_by_vector<\/span> для каждого из видов деятельности интервьюируемых, мы получаем список слов, которые наиболее точно описывают тематику текстов.<br \/>\nСтоит отметить, что слово «государство» стоит на первом месте не только в интервью политиков и бизнесменов, но и дизайнеров с писателями. Очевидно, что тема разговора у всех профессиональных групп смещена в сторону политики.<br \/>\nАктёры, кинокритики и музыканты описываются вполне закономерными для их сфер деятельности словами. А вот у фотографов нет ни слова про фотографию или творчество, но есть «работа», «трудоустройство», «существовать» и “семья”.<br \/>\nСравним риторику героев, построив box plot для каждой категории с помощью <span class=\"inline-code\">plotly<\/span>.<\/p>\n<pre class=\"e2-text-code\"><code>import plotly.express as px\r\n\r\nl = []\r\nfor el,ind in zip(df['Род деятельности'].value_counts(), df['Род деятельности'].value_counts().index):\r\n    if el &gt; 1:\r\n        l.append(ind)\r\n\r\ndf_kpi = df[df['Род деятельности'].isin(l)]\r\nfor kpi in ['Total Words', 'Unique Words','PPT Unique Words']:\r\n    buf_df = df_kpi[['Род деятельности',kpi]]\r\n    fig = px.box(df_kpi, \r\n                 x='Род деятельности',\r\n                 y=kpi,\r\n                )\r\n    fig.show()<\/code><\/pre><iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Bespalova\/3.embed?link=false\" height=\"650\" width=\"100%\"><\/iframe>\n<p>Наиболее разговорчивыми гостями оказались блогеры — и в среднем, и по медиане они наговорили больше всего слов. И опередили по этому показателю даже писателей. А вот самыми немногословными оказались рэперы, хотя, казалось бы, вот кто должен быть хорош в импровизации.<\/p>\n<iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Bespalova\/5.embed?link=false\" height=\"650\" width=\"100%\"><\/iframe>\n<p>Что касается количества уникальных слов, то и тут блогеры значительно ушли вперед. Согласно медианным значениям, тройка лидеров выглядит так — блогер, журналист и писатель. А вот словарный запас рэперов оставляет желать лучшего.<\/p>\n<iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Bespalova\/1.embed?link=false\" height=\"650\" width=\"100%\"><\/iframe>\n<p>Если говорить об отношении уникальных слов к общему количеству, то у всех групп гостей примерно одинаковый медианный показатель. Наиболее вариативными оказались музыканты — усы от их ящика показываю наибольший разброс значений.<\/p>\n",
            "date_published": "2021-06-07T11:26:28+03:00",
            "date_modified": "2023-05-19T15:03:12+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/Total-Words.png",
            "_date_published_rfc2822": "Mon, 07 Jun 2021 11:26:28 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "111",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/Total-Words.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/Unique-Words.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/PPT-Unique-Words.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-06-01--10.49.04.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-06-02--18.58.17.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-06-02--20.55.04.png"
                ]
            }
        },
        {
            "id": "110",
            "url": "http:\/\/test.leftjoin.ru\/all\/parser-indeed-with-python\/",
            "title": "Парсим вакансии для аналитиков из Indeed",
            "content_html": "<p>В этом материале мы расскажем, как парсить вакансии с сайта Indeed. Indeed — это крупнейший в мире поисковик вакансий. Этим текстом мы начинаем большой проект по анализу и визуализации показателей оплаты труда в области Data Science в разных странах.<br \/>\nПодобный анализ рынка вакансий, но только в России, мы проводили в материале <a href=\"http:\/\/test.leftjoin.ru\/all\/hh-dashboard-bi-and-analysts-market\/\">Анализ рынка вакансий аналитики и BI: дашборд в Tableau<\/a>, когда парсили данные с сайта HeadHunter.<\/p>\n<p class=\"note\">А еще у нас можно почитать материал  <a href=\"http:\/\/test.leftjoin.ru\/all\/parsim-dannye-kataloga-sayta-ispolzuya-beautiful-soup-i-selenium\/\">Парсим данные каталога сайта, используя Beautiful Soup и Selenium<\/a><\/p>\n<p><b>Импорт библиотек<\/b><br \/>\nБиблиотека <span class=\"inline-code\">fake_useragent<\/span> имитирует реальный User-Agent, чтобы преодолеть защиту сайта от парсинга. Таким образом мы сможем пройти проверку HTTP заголовка User-Agent.<br \/>\nМодуль <span class=\"inline-code\">urllib.parse<\/span> разбирает URL-адрес на компоненты и записывает его как кортеж. Он пригодится для перехода на карточки вакансий. BeautifulSoup поможет разобраться в структуре html-страницы и добыть нужную нам информацию.<\/p>\n<pre class=\"e2-text-code\"><code>import requests\r\nfrom datetime import timedelta, datetime\r\nimport urllib.parse\r\nfrom fake_useragent import UserAgent\r\nfrom bs4 import BeautifulSoup\r\nimport pandas as pd\r\nimport time\r\nfrom lxml.html import fromstring\r\nfrom clickhouse_driver import Client\r\nfrom clickhouse_driver import errors\r\nimport numpy as np\r\nfrom funcs import check_title, get_skills_row, parse_salary, get_sheetname, create_table<\/code><\/pre><p><b>Создадим таблицу в Clickhouse<\/b><br \/>\nДанные, которые мы собираемся собрать, будем хранить в базе Clickhouse.<\/p>\n<pre class=\"e2-text-code\"><code>create_table = '''CREATE TABLE if not exists indeed.vacancies (\r\n    row_idx UInt16,\r\n    query_string String,\r\n    country String,\r\n    title String,\r\n    company String,\r\n    city String,\r\n    job_added Date,\r\n    easy_apply UInt8,\r\n    company_rating Nullable(Float32),\r\n    remote UInt8,\r\n    job_id String,\r\n    job_link String,\r\n    sheet String,\r\n    skills String,\r\n    added_date Date,\r\n    month_salary_from_USD Float64,\r\n    month_salary_to_USD Float64,\r\n    year_salary_from_USD Float64,\r\n    year_salary_to_USD Float64,\r\n)\r\nENGINE = ReplacingMergeTree\r\nSETTINGS index_granularity = 8192'''<\/code><\/pre><p><b>Обход блокировок<\/b><br \/>\nНам нужно обойти защиту Indeed и избежать блокировки по IP. Для этого используем анонимные прокси адреса на сайте free-proxy-list.net. Как собрать свежие прокси, мы писали в нашем предыдущем тексте <a href=\"http:\/\/test.leftjoin.ru\/all\/selenium-proxy\/\" class=\"nu\">«<u>Пишем парсер свежих прокси на Python для Selenium<\/u>»<\/a>. Прокси адреса мы запишем в массив, который понадобится в момент обращения к Indeed, когда запрос будет проверять User-Agent.<\/p>\n<p>Данный метод удаляет IP из списка с прокси в том случае, если ответ от Indeed через него так и не пришел.<\/p>\n<pre class=\"e2-text-code\"><code>def remove_proxy_from_list_and_update_if_required(proxy):\r\n    global _proxies\r\n    _proxies.remove(proxy)\r\n    if len(_proxies) == 0:\r\n        update_proxy_list()<\/code><\/pre><p>Функция, используя прокси, возвращает нам страницу Indeed, из которой мы впоследствии спарсим данные.<\/p>\n<pre class=\"e2-text-code\"><code>def get_page(updated_url, session):\r\n    proxy = get_proxy()\r\n    proxy_dict = {&quot;http&quot;: proxy, &quot;https&quot;: proxy}\r\n    logger.info(f'try with proxy: {proxy}')\r\n    try:\r\n        session.proxies = proxy_dict\r\n        return session.get(updated_url, timeout=15)\r\n    except (requests.exceptions.RequestException, requests.exceptions.ProxyError, requests.exceptions.ConnectTimeout,\r\n            requests.exceptions.ReadTimeout, requests.exceptions.SSLError,\r\n            requests.exceptions.ConnectionError, url_ex.MaxRetryError, ConnectionResetError,\r\n            socket.timeout, url_ex.ReadTimeoutError):\r\n        remove_proxy_from_list_and_update_if_required(proxy)\r\n        logger.info(f'try with proxy {proxy}')\r\n        return get_page(updated_url, session)<\/code><\/pre><p><b>Методы для парсера<\/b><br \/>\nИскомые данные нужно будет искать по тегам и атрибутам верстки с помощью BeautifulSoup. Мы заранее собрали ключевые слова, которые нас будут интересовать в вакансиях, и подготовили с ними отдельный датасет.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-05-26--10.28.08.png\" width=\"1078\" height=\"686\" alt=\"\" \/>\n<\/div>\n<p>В карточках вакансий нет точной даты публикации, указано лишь сколько дней назад она была опубликована. Сохраним точную дату публикации в традиционном формате с помощью <span class=\"inline-code\">timedelta<\/span>.<\/p>\n<pre class=\"e2-text-code\"><code>def raw_date_to_str(raw_date):\r\n    raw_date = raw_date.lower()\r\n    if '+' in raw_date or &quot;более&quot; in raw_date:\r\n        delta = timedelta(days=32)\r\n        return (datetime.now() - delta).strftime(&quot;%Y-%m-%d&quot;)\r\n    else:\r\n        parts = raw_date.split()\r\n        for part in parts:\r\n            if part.isdigit():\r\n                delta = timedelta(days=part.isdigit())\r\n                return (datetime.now() - delta).strftime(&quot;%Y-%m-%d&quot;)\r\n    return &quot;&quot;<\/code><\/pre><p>Сохраним id вакансии в системе Indeed. Подставляя id в URL страницы, мы сможем получить доступ к полному описанию вакансий.<\/p>\n<pre class=\"e2-text-code\"><code>def get_job_id_from_card(card):\r\n    try:\r\n        return card['id'].split('_')[1]\r\n    except:\r\n        return &quot;&quot;<\/code><\/pre><p>Данный метод соберет названия вакансий.<\/p>\n<pre class=\"e2-text-code\"><code>def get_title_from_card(card):\r\n    try:\r\n        job_title = card.find('a', {'class': 'jobtitle'}).text\r\n        return job_title.replace('\\n', '')\r\n    except:\r\n        return ''<\/code><\/pre><p>Аналогичным образом напишем методы, которые будут собирать данные о названии компании, времени публикации объявления, местоположении работодателя и рейтинге работодателя на портале.<\/p>\n<p>URL сайта Indeed пишется для разных стран по-разному. Для США это будет просто indeed.com, а локализации для других стран получают префиксом xx.indeed.com. Список с префиксами мы собрали в массив заранее из <a href=\"\"><a href=\"https:\/\/opensource.indeedeng.io\/api-documentation\/docs\/supported-countries\/\">https:\/\/opensource.indeedeng.io\/api-documentation\/docs\/supported-countries\/<\/a> списка<\/a> Indeed.<\/p>\n<pre class=\"e2-text-code\"><code>def get_link_from_card(card, card_country):\r\n    try:\r\n        if card_country == 'us':\r\n            return f&quot;https:\/\/indeed.com{card.find('a', {'class': 'jobtitle'})['href']}&quot;\r\n        else:\r\n            return f&quot;https:\/\/{card_country}.indeed.com{card.find('a', {'class': 'jobtitle'})['href']}&quot;\r\n    except:\r\n        return &quot;&quot;<\/code><\/pre><p>Спарсим описание вакансии, которое можно найти по тегу ’summary’. Именно там содержатся требования, которые предъявляют к кандидату.<\/p>\n<pre class=\"e2-text-code\"><code>def get_summary_from_card_and_transform_to_skills(card):\r\n    try:\r\n        smr = card.find('div', {'class': 'summary'}).text\r\n        return get_skills_row(smr)\r\n    except:\r\n        return &quot;&quot;\r\nНеобходимые hard-skills из описания вакансий будем сверять со списком 'skills'. \r\nskills = [&quot;python&quot;, &quot;tableau&quot;, &quot;etl&quot;, &quot;power bi&quot;, &quot;d3.js&quot;, &quot;qlik&quot;, &quot;qlikview&quot;, &quot;qliksense&quot;,\r\n          &quot;redash&quot;, &quot;metabase&quot;, &quot;numpy&quot;, &quot;pandas&quot;, &quot;congos&quot;, &quot;superset&quot;, &quot;matplotlib&quot;, &quot;plotly&quot;,\r\n          &quot;airflow&quot;, &quot;spark&quot;, &quot;luigi&quot;, &quot;machine learning&quot;, &quot;amplitude&quot;, &quot;sql&quot;, &quot;nosql&quot;, &quot;clickhouse&quot;,\r\n          'sas', &quot;hadoop&quot;, &quot;pytorch&quot;, &quot;tensorflow&quot;, &quot;bash&quot;, &quot;scala&quot;, &quot;git&quot;, &quot;aws&quot;, &quot;docker&quot;,\r\n          &quot;linux&quot;, &quot;kafka&quot;, &quot;nifi&quot;, &quot;ozzie&quot;, &quot;ssas&quot;, &quot;ssis&quot;, &quot;redis&quot;, 'olap', ' r ', 'bigquery', 'api', 'excel']<\/code><\/pre><p>Эта функция разобьет ’summary’ на слова пробелом и проверит их на соответствие нашему списку. В датасет будут возвращаться совпадения с нашим списком hard-skills.<\/p>\n<pre class=\"e2-text-code\"><code>def get_skills_row(summary):\r\n    summary = summary.lower()\r\n    row = []\r\n    for sk in skills:\r\n        if sk in summary:\r\n            row.append(sk)\r\n    return ','.join(row)<\/code><\/pre><p>На выходе мы получим таблицу с примерно 30 тысячами строк.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-05-21--17.29.19.png\" width=\"1105\" height=\"658\" alt=\"\" \/>\n<\/div>\n<p>Полный код проекта можно посмотреть в нашем <a href=\"https:\/\/github.com\/valiotti\/leftjoin\/tree\/master\/indeed\"> репозитории<\/a> на GitHub.<\/p>\n",
            "date_published": "2021-05-27T10:10:46+03:00",
            "date_modified": "2021-05-26T12:43:15+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/--2021-05-26--10.28.08.png",
            "_date_published_rfc2822": "Thu, 27 May 2021 10:10:46 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "110",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-05-26--10.28.08.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-05-21--17.29.19.png"
                ]
            }
        }
    ],
    "_e2_version": 3365,
    "_e2_ua_string": "E2 (v3365; Aegea)"
}