Анализ отзывов с banki.ru [Часть 1] История изменений в SCD таблице
Данный материал представлен в информационно-ознакомительных целях.
Список частей:
1
часть: Сбор данных ссылка2
часть: Визуализация ссылка3
часть: Мониторинг загрузок ссылка4
часть: Создание сайта и RESTAPI ссылка
Короткое вступление:
В этой статье я хочу показать на примере, как можно собрать данные из открытого источника, на примере сайта banki.ru, страницы народного рейтинга банков - отзывов клиентов.
Начнем:
Для начала, давайте определим: какие данных из отзыва нас будут интересовать?
- номер отзыва (id)
- ссылка (url)
- название (заголовок отзыва)
- город
- название банка
- оценка (цифровое значение)
- статус
- логин/почта клиента
- дата создания отзыва
- кол-во комментариев
- сам отзыв
- ответ банка
- дата ответа банка
- ответ администратора
- дата ответа администратора
Вот пример такой страницы, на которой присутствует вся вышеперечисленная информация.
Технологии:
В качестве инструмента для сбора данных будем использовать python3
, для хранения PostgreSQL
базу данных.
Список дополнительных модулей python
:
- psycopg2
- beautifulsoup4
Функция принимает сырой html
-текст, преобразовывает его в дерево объектов, которое принимают в качестве аргумента отдельные функции.
def get_review_info(html: str):
tree = bs(html, 'lxml')
data = {
'id': _get_id_response_from_html(tree),
'link': _get_link_from_html(tree),
'title': _get_title_from_html(tree),
'city': _get_city_from_html(tree),
'bank_name': _get_bank_name_from_html(tree),
'score': _get_score_from_html(tree),
'status': _get_score_status_from_html(tree),
'username': _get_username_from_html(tree),
'create_dt': _get_create_date_from_html(tree),
'comments': _get_comments_count_from_html(tree),
'content': _get_content_from_html(tree),
'bank_answer': _get_bank_answer_from_html(tree),
'bank_answer_date': _get_date_bank_answer_from_html(tree),
'admin_answer': _get_admin_answer_from_html(tree),
'admin_answer_date': _get_date_admin_answer_from_html(tree),
'parse_dt': _get_parse_date(),
}
return data
Следующим этапом будет “перенос” информации в базу данных, для этого будем использовать универсальную функцию на вставку, для обновления реализуем конструкцию insert on conflict
:
Создадим таблицу:
create table if not exists home.dt_banki_responses (
id int primary key,
link varchar(255) not null,
title varchar(255) not null,
city varchar(255) not null,
bank_name varchar(255) not null,
score integer null,
status varchar(100) null,
username varchar(100) null,
create_dt timestamptz not null,
comments integer null,
content text not null,
bank_answer text null,
bank_answer_date timestamptz null,
admin_answer text null,
admin_answer_date timestamptz null,
parse_dt date not null
);
def insert_on_conflict(table: str, array: List, sql: str) -> None:
item = array[0]
columns = ', '.join(item.keys())
dd = ["%s" for _ in range(len(item.keys()))]
placeholders = ", ".join(dd)
row = sql.format(table, columns, placeholders)
cursor.executemany(row, [tuple(item.values()) for item in array])
conn.commit()
3-й аргумент функции - это некий темплейт на обновление данных конкретной таблицы, данные будут обновляться только в том случае, если удовлетворяют условиям:
- Смена статуса
- Изменение оценки
- Изменение кол-ва комментариев под отзывом
insert into {} as t ({}) values ({}) on conflict (id)
do update set
title = excluded.title,
city = excluded.city,
bank_name = excluded.bank_name,
score = excluded.score,
status = excluded.status,
username = excluded.username,
create_dt = excluded.create_dt,
comments = excluded.comments,
content = excluded.content,
bank_answer = excluded.bank_answer,
bank_answer_date = excluded.bank_answer_date,
admin_answer = excluded.admin_answer,
admin_answer_date = excluded.admin_answer_date,
parse_dt = excluded.parse_dt
where
t.status <> excluded.status
or (t.score <> excluded.score or t.comments <> excluded.comments)
Анализ данных
С начала 2021 года клиентами было создано более 4800 отзывов, давайте проведем легкий анализ:
Рассчитаем редняю и медианную оценку
, к сожалению готовой функции median
в postgresql
нет, поэтому напишем свой вариант.
Напомню, медиана
исключает
выбросы по значению, простыми словами - она берет значение элемента из середины отсортированного списка
Источник: PostgreSQL Aggregate Median
CREATE OR REPLACE FUNCTION _final_median(numeric[])
RETURNS numeric AS
$$
SELECT AVG(val)
FROM (
SELECT val
FROM unnest($1) val
ORDER BY 1
LIMIT 2 - MOD(array_upper($1, 1), 2)
OFFSET CEIL(array_upper($1, 1) / 2.0) - 1
) sub;
$$
LANGUAGE 'sql' IMMUTABLE;
CREATE AGGREGATE median(numeric) (
SFUNC=array_append,
STYPE=numeric[],
FINALFUNC=_final_median,
INITCOND='{}'
);
select
avg(score) as avg_valie, median(score) as median_value
from
home.dt_banki_responses
where
date(create_dt) >= '2021-01-01';
Результат: 3.8
- среднее, 5
- медиана, весьма неплохо, давайте повторим тот же запрос в разрезе месяца
2021-01 Среднее = 2.2
, Медиана = 1
2021-02 Среднее = 2.8
, Медиана = 3
2021-03 Среднее = 2.4
, Медиана = 1
2021-04 Среднее = 2.2
, Медиана = 1
2021-05 Среднее = 2.2
, Медиана = 1
2021-06 Среднее = 3.8
, Медиана = 5
2021-07 Среднее = 4.5
, Медиана = 5
2021-08 Среднее = 2.4
, Медиана = 2
2021-09 Среднее = 1.9
, Медиана = 1
Странное распределение оценок по месяцам, учитывая что средняя и медиана >3.5
, давайте добавим общее кол-во и кол-во в разрезе оценки
select
substr(create_dt::varchar,1,7) as month
, round(avg(score), 1) as "Среднее"
, trunc(median(score)) as "Медиана"
, count(*) as "Общее кол-во"
, sum(case when score = 1 then 1 else 0 end) as "Оценка 1"
, sum(case when score = 2 then 1 else 0 end) as "Оценка 2"
, sum(case when score = 3 then 1 else 0 end) as "Оценка 3"
, sum(case when score = 4 then 1 else 0 end) as "Оценка 4"
, sum(case when score = 5 then 1 else 0 end) as "Оценка 5"
, sum(case when score is null then 1 else 0 end) as "Без оценки"
from
home.dt_banki_responses
where
date(create_dt) >= '2021-01-01'
group by substr(create_dt::varchar,1,7)
order by substr(create_dt::varchar,1,7) asc;
Результат. Весьма странно, что на июль
пришлось больше 50% отзывов (в банке была акция: 500р
за положительный отзыв):
Давайте проверим аналогичнную информацию в разразе статуса обращения
select
status as "Статус"
, count(*) as "Кол-во"
from
home.dt_banki_responses
where
date(create_dt) >= '2021-01-01'
group by status
order by 2 desc;
- Не засчитана
2878
- Без статуса
967
- Проверяется
402
- Проблема решена
358
- Зачтено
235
Большинство отзывов в статусе Не засчитана
, проверим на какую оценку приходится большинство из них
select
status as "Статус"
, count(*) as "Кол-во"
, sum(case when score = 1 then 1 else 0 end) as "Оценка 1"
, sum(case when score = 2 then 1 else 0 end) as "Оценка 2"
, sum(case when score = 3 then 1 else 0 end) as "Оценка 3"
, sum(case when score = 4 then 1 else 0 end) as "Оценка 4"
, sum(case when score = 5 then 1 else 0 end) as "Оценка 5"
, sum(case when score is null then 1 else 0 end) as "Без оценки"
from
home.dt_banki_responses
where
date(create_dt) >= '2021-01-01'
group by status
order by 2 desc;
Результат: любопытно, ,большая часть оценок 5
находятся в стутусе Не засчитана
.
2202 / 2878 * 100 = 70%
Повторим первый запрос на среднюю и медианную оценку, но с фильтром на статусы: Проблема решена
и Зачтено
select
substr(create_dt::varchar,1,7) as month
, round(avg(coalesce(score, 0)), 1) as "Среднее"
, trunc(median(coalesce(score, 0))) as "Медиана"
, count(*) as "Общее кол-во"
, sum(case when score = 1 then 1 else 0 end) as "Оценка 1"
, sum(case when score = 2 then 1 else 0 end) as "Оценка 2"
, sum(case when score = 3 then 1 else 0 end) as "Оценка 3"
, sum(case when score = 4 then 1 else 0 end) as "Оценка 4"
, sum(case when score = 5 then 1 else 0 end) as "Оценка 5"
, sum(case when score is null then 1 else 0 end) as "Без оценки"
from
home.dt_banki_responses
where
date(create_dt) >= '2021-01-01'
and status in ('Проблема решена', 'Зачтено')
group by substr(create_dt::varchar,1,7)
order by substr(create_dt::varchar,1,7) asc;
Теперь статистка выглядит не так радужно
, среднее ~0.75
, медиана ~1
Далее, давайте посмотрим на время отклика сотрудников банка на обращения в статусах ('Проблема решена', 'Зачтено')
:
select
round(
avg(date_part('day', bank_answer_date-create_dt)::int), 1
) as "Среднее время ответа в днях"
, round(
median(date_part('day', bank_answer_date-create_dt)::int), 1
) as "Медианное время ответа в днях"
from
home.dt_banki_responses
where
date(create_dt) >= '2021-01-01'
and status in ('Проблема решена', 'Зачтено')
and bank_answer_date is not null;
- Среднее время оклика сотрудника =
2.9
дня - Медианное время оклика сотрудника =
1
день
Slowly changing dimension (SCD)
Википедия
Медленно меняющиеся измерения (от англ. Slowly Changing Dimensions, SCD) — механизм отслеживания изменений в данных измерения в терминах хранилища данных. Применяется в случае, если данные меняются не очень часто и не по расписанию. Примером могут служить географические данные (местонахождение склада, юридический адрес организации), статус заказчика по программе лояльности или отдел компании, в котором работает её сотрудник.
Анализ актуальных данных это конечно прекрасно, но хотелось бы также понимать, какая оценка, статус или кол-во комментариев было 1 месяц/неделю назад. Для этого реализуем механизм ведения второй таблицы, которая будет хранить в себе историю изменений данных, включая метод и время транзации.
Добавим 2 новых поля: action
и valid_from
и создадим новую таблицу home.dth_banki_responses
:
create table if not exists home.dth_banki_responses (
id int,
link varchar(255) not null,
...
action char(1) not null,
valid_from timestamptz not null
);
Данные в нее будут попадать автоматически, реализуем это с помощью функции и триггера.
CREATE OR REPLACE FUNCTION home.banki_hist_insert()
RETURNS trigger
LANGUAGE plpgsql AS
$function$
BEGIN
INSERT INTO home.dth_banki_responses
(id, link, ... action, valid_from)
VALUES
(NEW.id, NEW.link, ... 'I', current_timestamp);
RETURN NEW;
END;
$function$;
Функция будет выполнятся после вставки
в основную таблицу, по аналогии сделаем для обновления
и удаления
create trigger banki_hist_insert_trigger after insert on home.dt_banki_responses
for each row execute procedure home.banki_hist_insert();
Посмотрим пример изменившихся данных ссылка:
select
id
, title
, create_dt
, score
, status
, comments
, action
, valid_from
, coalesce(
lead(valid_from) over(partition by id order by valid_from asc), '2999-12-31'
) as valid_to
from
home.dth_banki_responses
where
id = 10556512
order by
valid_from asc;
Как видно - изменилось название и статус обращения, с помощью функции LEAD
добавим правую
дату актуальности информации