Выбор кадастрового инженера с помощью Data Science

от автора

Для кого эта статья

Статья может быть полезна для:

  • Начинающих Data Scientist’ов (как я), чтобы посмотреть/попробовать инструменты интерактивной визуализации (включая геоданные) для РФ

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

  • Чиновников, отвечающих за деятельность Росрееста и кадастровых инженеров

  • Чиновников, отвечающих за развитие бизнеса и пытающихся понять, где есть «узкие места»

Автор: Предприниматель, начинающий Data Scientist на досуге.

Предыстория

Заканчивался 1 квартал 2020 года, ажиотаж вокруг пандемии ковид в РФ был на своем пике. Симптоматика первых переболевших показывала, что даже в случае относительно легко перенесенной болезни вопрос реабилитации и восстановления работоспособности (в том числе и психологическо-когнитивной) — встает на первое место. И мы наконец-то решили «Хватит сидеть, пора делать свое дело. Если не сейчас, то когда?!». В условиях повсеместной удаленки нашли иностранного профильного партнера-инвестора и разработали адаптированный к РФ концепт клиники/пансионата по реабилитации пациентов после перенесенного ковида. Ключевым риском для инвесторов была возможная скорость реализации проекта (после пандемии предполагалась реконцепция клиники в многопрофильный реабилитационный центр — а это существенно большие инвестиции и сроки окупаемости) — поэтому было важно стартовать как можно быстрее.

Команда проекта была преисполнена энтузиазма, готова соинвестировать и мы договорились с инвесторами, что основной транш инвестиций пойдет не на стройку, а на расширение и оборудование приобретенных командой площадей. Мы достаточно быстро нашли несколько подходящих объектов в Московской области, но самым интересным показался объект, реализуемый Агентством по Страхованию Вкладов в рамках банкротство одного из банков РФ. Взвесив все «за» и «против», мы приняли решение об участии в публичных торгах и выкупили объект. Окрыленные победой на торгах, мы быстро заключили ДКП, произвели оплату и подали документы в Росреестр на регистрацию сделки. Не ожидая никаких подвохов с регистрацией (все-таки продавец — АСВ, торги — публичные, имущество — банковское) мы сразу же начали переговоры с подрядчиками по реновации и строительству. Как же мы тогда ошибались…

Сейчас, спустя уже полтора года после сделки, мы все еще не зарегистрировали право собственности на купленные объекты. Зато:

  • Пообщались (и получили от этого колоссальное количество негативного опыта) с десятками кадастровых инженеров.

  • Подали в Росреестр бессчётное количество межевых планов с исправлением их же ошибок, но на каждый получили бессодержательные отписки.

  • Площадь одного из участков Росреестр в одностороннем порядке уменьшил (!) на 2 гектара. Без уведомлений ни АСВ, ни нас как покупателя.

  • Ходим на заседания судов как на работу (за эту работу еще и платим юристам).

  • Познакомились с соседями, которые неожиданно «почуяв» деньги, подумали, что у них есть претензии территориального характера.

По идее, в нормально работающей системе, проблемы земельных участков решаются кадастровыми инженерами — специально обученными и аттестованными Росреестром людьми. Если вы еще не сталкивались с данной когортой — мы искренне рады за вас. Они работают следующим образом (наш опыт): Никто ничего не гарантирует (но деньги вперед платите, пожалуйста). Сроки — тянутся. Есть откровенные хамы (в буквальном смысле), которые на вас могут наорать на этапе обсуждения договора, если вы подумаете, например, предложить оплату только в случае успеха.

И мы подумали: ну не могут же быть все плохие (спойлер: по статистике — могут). Ну не может же Росреестр блокировать все решения (спойлер: по статистике — может), ведь из каждого утюга сейчас говорят о том, как же важно поддерживать бизнес и предпринимателей. А о каком бизнесе может идти речь, если ты даже не можешь зарегистрировать землю?
Наверное, мы просто неправильных кадастровых инженеров выбирали (мы поработали уже с 4я) — давайте найдем объективные данные и по ним выберем хорошего кадастрового инженера.

Данные Росреестра

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

На момент написания статьи (Апрель-Май 2022) были доступны данные по 4 кв. 2021 года включительно. К сожалению, удобных методов выгрузить данные сайт не предоставляет. Поэтому на фриланс-бирже был найден профессиональный исполнитель, который всего за несколько дней (сайт Росреестра работает очень медленно, поэтому этот результат считаю очень хорошим смог собрать данные. Наверняка он без сна и отдыха ручками прокликал без малого 40 тыс. кадастровых инженеров в интуитивно понятном и дружественном интерфейсе сайта.

Приступаем к анализу

Импортируем необходимые библиотеки

import bokeh.io import geopandas as gpd import numpy as np import pandas as pd import pandas_bokeh from bokeh.io import output_notebook, reset_output, show from bokeh.models import ColumnDataSource, HoverTool, NumeralTickFormatter from bokeh.palettes import Viridis3, Viridis256, viridis from bokeh.plotting import ColumnDataSource, figure, output_file, save, show from bokeh.resources import INLINE from bokeh.transform import linear_cmap  bokeh.io.output_notebook(INLINE)
# Замьютим предупреждения от shapely и определим вывод графиков в ноутбук import warnings from shapely.errors import ShapelyDeprecationWarning warnings.filterwarnings("ignore", category=ShapelyDeprecationWarning)  output_notebook()
dt_dict = {     "general_info" : {"path" :"./PARSED DATA/general.xlsx"},     "statistics_1" : {"path" :"./PARSED DATA/statistics_1.xlsx"},     "statistics_2" : {"path" :"./PARSED DATA/statistics_2.xlsx"},     "sro_membership": {"path" :"./PARSED DATA/sro.xlsx"},     "penalties": {"path" :"./PARSED DATA/discipline.xlsx"}, }
# Читаем данные, смотрим базовую информацию for data_name, data_name_dict in dt_dict.items():     data_path = data_name_dict.get("path")     data_raw = pd.read_excel(data_path)     data_name_dict["data_raw"] = data_raw     display(data_raw.head(3))     display(data_raw.info())

Предобработка данных

Необходимые шаги предобработки данных:

Таблица: Общая информация:

  • Сплит данных аттестата: att_numberatt_date

  • Сплит ФИО: first_namelast_name , middle_name

  • reg_numberfloat -> int

  • Коррекция типа данных для колонок с датами

  • Переименование колонок по словарю

Таблица: Членство в СРО

  • Коррекция типа данных для колонок с датами;

  • Переименование колонок по словарю;

Таблица: Дисциплинарные взыскания

  • Коррекция типа данных для колонок с датами;

  • Переименование колонок по словарю;

  • Перевод в нижний регистр тип взыскания.

Таблица: Статистика:

  • Объединение 2х файлов статистики деятельности;

  • Создание колонки statistics_period из year + period (квартал) в формате дат pandas;

  • Переименование колонок по словарю.

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

# Определим словарь переименования rename_columns_dict = {     # Все таблицы     "ID":"id",     # Таблица дисциплинарных взысканий     "Мера ДВ": "penalty_type",     "Дата решения о применении меры ДВ": "penalty_decision_date",     "Основание применения меры ДВ":"penalty_decision_reason",     "Дата начала ДВ": "penalty_start_date",     "Дата окончания ДВ": "penalty_end_date",     # Таблица членства в СРО     "date_sro_incl" : "sro_inclusion_date",     "date_sro_excl" : "sro_exclusion_date",     "sro_excl_reason": "sro_exclusion_reason",     # Таблица общей информации     "date_added" : "added_date",     "date_sro": "sro_date",     "name": "full_name",     # Таблица статистики     "total_decisions": "decisions_total",     "rejections_27fz": "decisions_27fz", }
def clean_general_df(df_to_clean:pd.DataFrame) -> pd.DataFrame:     """Function to clean raw general info. Returns cleaned df"""     df_clean = (         df_to_clean.copy()         # Разбираем attestat на необходимые поля         .assign(att_number = lambda x: x.attestat.str.split("_").str[0])#.str.split(" ").str[1])         .assign(att_number = lambda x: x.att_number.str.split(" ").str[1])         .assign(att_date = lambda x: x.attestat.str.split("дата выдачи: ").str[1])         .drop("attestat", axis=1)         # Разбираем ФИО. При такой реализации могут быть ошибки в нестандартных именах         .assign(first_name = lambda x: x.name.str.split(" ").str[1])         .assign(last_name = lambda x: x.name.str.split(" ").str[0])         .assign(middle_name = lambda x: x.name.str.split(" ").str[-1])         # Переименовываем колонки по словарю         .rename(columns=rename_columns_dict)         # Меняем формат данных          .assign(reg_number = lambda x: x.reg_number.astype("Int64"))         # Меняем формат дат         .assign(added_date = lambda x: pd.to_datetime(x.added_date, format="%d.%m.%Y", errors="ignore").dt.date)         .assign(sro_date = lambda x: pd.to_datetime(x.sro_date, format="%d.%m.%Y", errors="ignore").dt.date)         .assign(att_date = lambda x: pd.to_datetime(x.att_date, format="%d.%m.%Y", errors="ignore").dt.date)     )     return df_clean    def clean_sro_membership_df(df_to_clean:pd.DataFrame) -> pd.DataFrame:     """Function to clean raw SRO membership info. Returns cleaned df"""     df_clean = (         df_to_clean.copy()         # Переименовываем колонки по словарю         .rename(columns=rename_columns_dict)         # Меняем формат дат         .assign(sro_inclusion_date = lambda x: pd.to_datetime(x.sro_inclusion_date, format="%d.%m.%Y", errors="coerce").dt.date)         .assign(sro_exclusion_date = lambda x: pd.to_datetime(x.sro_exclusion_date, format="%d.%m.%Y", errors="coerce").dt.date)     )     return df_clean   def clean_penalties_df(df_to_clean:pd.DataFrame) -> pd.DataFrame:     """Function to clean raw penalties info. Returns cleaned df"""     df_clean = (         df_to_clean.copy()         # Переименовываем колонки по словарю         .rename(columns=rename_columns_dict)         # Меняем формат дат         .assign(penalty_decision_date = lambda x: pd.to_datetime(x.penalty_decision_date, format="%d.%m.%Y", errors="coerce").dt.date)         .assign(penalty_start_date = lambda x: pd.to_datetime(x.penalty_start_date, format="%d.%m.%Y", errors="coerce").dt.date)         .assign(penalty_end_date = lambda x: pd.to_datetime(x.penalty_end_date, format="%d.%m.%Y", errors="coerce").dt.date)         # Переводим в нижний регистр тип взыскания         .assign(penalty_type = lambda x: x.penalty_type.str.lower())     )     return df_clean  def clean_statistics_df(df_to_clean:pd.DataFrame) -> pd.DataFrame:     """Function to clean raw statistics info. Returns cleaned df"""     df_clean = (         df_to_clean.copy()         # Переименовываем колонки по словарю         .rename(columns=rename_columns_dict)         # Создадим колонку с периодами деятельности         .assign(statistics_period = lambda x: x.period.astype(str)+ "-"+ x.year.astype(str))          .assign(statistics_period = lambda x: (pd.to_datetime(x.statistics_period, format="%m-%Y",errors="coerce") + pd.offsets.MonthEnd(0)).dt.date)         .assign(quarter = lambda x: (x.period/3).astype("int64"))     )     return df_clean
# Сохраним очищенные данные в общий словарь dt_dict["general_info"]["data_clean"] = clean_general_df(dt_dict["general_info"]["data_raw"]) dt_dict["statistics_1"]["data_clean"] = clean_statistics_df(dt_dict["statistics_1"]["data_raw"]) dt_dict["statistics_2"]["data_clean"] = clean_statistics_df(dt_dict["statistics_2"]["data_raw"]) dt_dict["sro_membership"]["data_clean"] = clean_sro_membership_df(dt_dict["sro_membership"]["data_raw"]) dt_dict["penalties"]["data_clean"] = clean_penalties_df(dt_dict["penalties"]["data_raw"]) dt_dict["statistics"] = {"data_clean": pd.concat([dt_dict["statistics_1"]["data_clean"], dt_dict["statistics_2"]["data_clean"], ])}  for k, v in dt_dict.items():     display(v.get("data_clean").head(3))

Анализ

Анализ выданных аттестатов кад. инженеров

Посмотрим внимательнее на признак «Номер аттестата» att_number и попробуем понять, значат ли что-то цифры его составляющие. Больше всего мы бы хотели вытащить информацию о регионах выдачи аттестатов и, может быть, одна из цифр кодирует регион. Если это так, то регионов должно быть около 85, а максимальное количество инженеров ожидается в 50-м, 77-м, 78-м регионах.

_att_number_df = dt_dict["general_info"].get("data_clean")["att_number"] _att_number_df = _att_number_df.str.split("-", expand=True) _att_number_df = _att_number_df.dropna() _att_number_df = _att_number_df.astype("int64", errors="raise") _att_number_df = _att_number_df.rename(columns={0: "smt_0", 1: "smt_1", 2: "smt_2"}) display(_att_number_df["smt_0"].nunique()) display(_att_number_df["smt_1"].nunique()) display(_att_number_df["smt_2"].nunique())

83

7

1577

Отлично! Гипотеза пока не опровергнута.

  • Количество уникальных объектов первой части атт.номера близко кол-ву субъектов рф;

  • Вторая часть номера тоже представляет интерес: уникальных значений всего 7. Но что это может быть — пока не понятно.

Проверим количество аттестатов по предполагаемому признаку региона

# Посчитаем количество аттестатов по регионам regions_att_count = (     _att_number_df.groupby(by="smt_0")     .agg(attestat_count=("smt_0", "count"))     .sort_values(by="attestat_count", ascending=False) ).reset_index().rename(columns={"smt_0":"region"}) regions_att_count["rank_by_count"] = regions_att_count["attestat_count"].rank(ascending=False) regions_att_count.head(5)

Визуализируем данные. Для образовательных целей данного проекта графики будут строиться с помощью библиотеки bokeh. Да, есть более удобные высокоуровневые библиотеки. Например, pandas-bokeh или hvplot. Последний даже мождет выступать в качестве бэкенда для графиков pandas вместо дефолтного matplotlib (pandas plotting backend docs). Однако хочется лучше понять bokeh и, в первую очередь, его возможности по более низкоуровневой кастомизации графиков.

source = ColumnDataSource(regions_att_count)  # Определяем цветовой mapper для раскраски в зависимости от количества аттестатов mapper = linear_cmap(     field_name="attestat_count",     palette=Viridis256,     low=min(regions_att_count.attestat_count),     high=max(         regions_att_count.attestat_count,     ), )  # Определим данные, показываемые при наведении на график # Наведем красоту в форматах представления данных TOOLTIPS = [     ("Код региона", "@region"),     ("Количество аттестатов", "@attestat_count{0.0a}"),     ("Ранг по кол-ву аттестатов", "@rank_by_count{0o}"), #1 -> 1st, 2 -> 2nd ]  p = figure(     plot_height=400,     plot_width=1000,     tooltips = TOOLTIPS,     title = "Количество аттестатов по предполагаемому атрибуту региона" )  p.vbar(     x="region",     top="attestat_count",     color=mapper,     source=source, )  # Кастомизируем оси p.xaxis.axis_label = "Код региона" p.yaxis.formatter = NumeralTickFormatter(format='0a') p.yaxis.axis_label = "Количество аттестатов"  show(p)

Интерактивный график доступен по ссылке

Гипотеза о том, что в номере аттестата закодирован регион выдачи, кажется, подтвердилась.

  • Наибольшее количество аттестатов выдано в Москве (77);

  • Удивительно, но на 2-м и 3-м месте, обгоняя Московскую Область (50), находятся Краснодарский край (23) и Республика Башкортостан (02).

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

# Скачаем данные о регионах из репозитория HFLabs. Спасибо ребятам за инфо в удобном формате region_naming = pd.read_csv(     "https://raw.githubusercontent.com/hflabs/region/master/region.csv",     dtype=object, )  # Достаем необходимые поля из таблицы регионов geoname_df = region_naming.loc[:, ["kladr_id", "geoname_name", "iso_code"]] geoname_df["code"] = geoname_df["kladr_id"].str[0:2] #geoname_df["iso_code"] = geoname_df["iso_code"].str.replace("-", ".") display(geoname_df.head(3))  # Смерджим данные в датафрейм с основной информацией general_info_clean = dt_dict["general_info"].get("data_clean").copy() general_info_clean["att_region"] = (     general_info_clean["att_number"].str.split("-", expand=False).str[0] ) general_info_clean = general_info_clean.merge(     geoname_df[["geoname_name", "code", "iso_code"]],     how='left',     left_on="att_region",     right_on="code", ).drop("code", axis=1)  # Посмотрим на результат и сохраним в словаре с данными display(general_info_clean.head(3)) dt_dict["general_info"]["data_clean"]  = general_info_clean 

Анализ статистики деятельности кад.инженеров

Проанализируем статистику деятельности кад.инженеров во времени:

  • Посчитаем суммарное количество отказов по всем причинам;

  • Посчитаем долю отказов.

(!) Так как обработка документов Росреестром растянуто во времени, могут быть кварталы, когда количество полученных в периоде отказов (по сути, по поданным ранее документам) превышает количество поданных в периоде документов

statistics_df = dt_dict["statistics"]["data_clean"] cols_to_sum = ["decisions_27fz", "decisions_mistakes", "decisions_suspensions"] statistics_df["rejections_total"] = statistics_df[cols_to_sum].sum(axis=1) statistics_df["acceptions_total"] = statistics_df["decisions_total"] - statistics_df["rejections_total"] statistics_df["rejections_share"] = (     statistics_df["rejections_total"] / statistics_df["decisions_total"] ) statistics_df["acceptions_share"] = (     statistics_df["acceptions_total"] / statistics_df["decisions_total"] ) display(statistics_df.head(3))

Посмотрим на агрегированную статистику отказов во времени. Так как отказы Росрееста получаются с временным лагом, возможна ситуация, когда доля отказов > 1. Данные по кварталам представлены накопленным итогом.

# Подготовим данные для Bokeh _df = statistics_df.replace([np.inf, -np.inf], np.nan, inplace=False).dropna() # Посчитаем суммарное количество принятых и отвергнутых документов по кварталам period_stat_df = (     _df[["acceptions_total", "rejections_total", "statistics_period"]]     .groupby("statistics_period")     .sum()     .reset_index() ) period_stat_df["rejections_share"] = period_stat_df["rejections_total"] / (     period_stat_df["rejections_total"] + period_stat_df["acceptions_total"] ) source = ColumnDataSource(period_stat_df)  # Определим данные, показываемые при наведении на график # И наведем красоту в форматах отображения данных hover_1 = HoverTool(     tooltips=[     ("Период", "@statistics_period{%m-%Y}"),     ("Количество аксептов", "@acceptions_total{0.0a}"),     ("Количество отказов", "@rejections_total{0.0a}"),     ("Доля отказов", "@rejections_share{0.0%}"), ],     formatters={         "@statistics_period"        : 'datetime', # use 'datetime' formatter for '@date' field     }, )  # Возьмем 2 цвета из палитры Viridis colors = viridis(2)  # Определяем график p = figure(     width=1000,     height=400,     title="Поквартальное (накопленное за год) количество одобренных и отвегнутых документов",     toolbar_location=None,     x_axis_type="datetime",     tools=[hover_1], )  # Добавляем на график данные p.vbar_stack(     ["acceptions_total", "rejections_total"],     # Ось Х - это ось времени, где базовая единица миллисекунда.     # Поэтому ширину столбцов необходимо указывать достаточно большую     width=5e9,     x="statistics_period",     color=colors,     source=source, )  # Кастомизируем названия осей p.xaxis.axis_label = "Период" p.yaxis.formatter = NumeralTickFormatter(format='0.0a') p.yaxis.axis_label = "Количество документов"  show(p)

Интерактивный график доступен по ссылке

Мы видим, что с начала 2019 года явно поменялась структура и/или подход к проверке документов: при сохранении общей динамики и сезонности, количество отказов возрасло многократно. В вики ведомства ничего примечательного относительно 2018-2019 годов не написано, да и на TAdvisor тоже ничего примечательного в данные периоды.
Также достаточно странным выглядит околонулевой выброс отказов в 1 кв. 2020 года.

Посмотрим на динамику доли отказов

# Подготовим данные дополнив расчетом доли принятых документов period_stat_df["acceptions_share"] = period_stat_df["acceptions_total"] / (     period_stat_df["rejections_total"] + period_stat_df["acceptions_total"] )  source_2 = ColumnDataSource(period_stat_df)  # Определим данные, показываемые при наведении на график # И наведем красоту в форматах отображения данных hover_2 = HoverTool(     tooltips=[     ("Период", "@statistics_period{%m-%Y}"),     #("Количество аксептов", "@acceptions_total{0.0a}"),     #("Количество отказов", "@rejections_total{0.0a}"),     ("Доля отказов", "@rejections_share{0.0%}"),     ("Доля акцептов", "@acceptions_share{0.0%}"), ],     formatters={         "@statistics_period"        : 'datetime', # use 'datetime' formatter for '@date' field     }, )  # Возьмем 2 цвета из палитры Viridis colors = viridis(2)  # Определяем график p2 = figure(     width=1000,     height=400,     title="Поквартальная (накопленная за год) доля одобренных и отвегнутых документов",     toolbar_location=None,     x_axis_type="datetime",     tools=[hover_2], )  # Добавляем на график данные p2.vbar_stack(     ["acceptions_share", "rejections_share"],     # Ось Х - это ось времени, где базовая единица миллисекунда.     # Поэтому ширину столбцов необходимо указывать достаточно большую     width=5e9,     x="statistics_period",     color=colors,     source=source_2, )  # Кастомизируем названия осей p2.xaxis.axis_label = "Период" p.yaxis.formatter = NumeralTickFormatter(format='0%') p2.yaxis.axis_label = "Доля документов"  show(p2) 

Интерактивный график доступен по ссылке

Геовизуализация

Визуализируем данные на карте России. Для этого получим границы регионов с помощью OpenStreetMaps и его Overpass API для запросов. Большой аналитической ценности в данном этапе анализа нет — все можно увидеть в таблице, но так как цель данного анализа образовательная, то очень хотелось научиться визуализировать данные именно по РФ и реализовать и этот функционал.

Для тех, кто хотел бы чуть больше узнать о геоданных/геовизуализации крайне рекомендую архив курса Университета Хельсинки Henrikki Tenkanen and Vuokko Heikinheimo, Digital Geography Lab, University of Helsinki. Наверное, это один из лучших и комплексных мануалов, расказывающий (кратко, но по делу) весь процесс end-to-end

import pandas as pd import requests import geopandas as gpd from osm2geojson import json2geojson  # Создадим запрос административных границ регионов overpass_url = "http://overpass-api.de/api/interpreter" overpass_query = """     [out:json];     rel[admin_level=4]     [type=boundary]     [boundary=administrative]     ["ISO3166-2"~"^RU"];     out geom;     """ # Запрашиваем данные и формируем GeoDataFrame response = requests.get(overpass_url,                          params={'data': overpass_query}) response.raise_for_status() data = response.json() geojson_data = json2geojson(data) gdf_osm = gpd.GeoDataFrame.from_features(geojson_data)   # Конвертируем словари тэгов ответа на запрос в колонки df_tags = gdf_osm["tags"].apply(pd.Series)  # Определим, какие колонки оставить для дальнейшего анализа # По сути - удалим переводы наименовая регионов на разные языки, оставив ru и en cols_keep = [] for col in list(df_tags.columns):     if "name:" not in col:         cols_keep.append(col) cols_keep.extend(["name:en", "name:ru"])  # Получим финальный геодатафрейм с нужными колонками gdf_full = pd.concat([gdf_osm, df_tags.loc[:,cols_keep]], axis=1) display(gdf_full.head())

Наиболее быстрый и простой метод построить интерактивную карту с Bokeh — воспользоваться более высокоуровневой библиотекой pandas_bokeh, которая поддерживает данные геоформата. Приведем пример и выведем интерактивную карту регионов РФ.

# Установим координатную систему Coordinate Reference System (CRS) gdf_full_mercator = gdf_full.set_crs('epsg:4326')  gdf_full_mercator.plot_bokeh(     figsize = (1000, 600),     simplify_shapes=20000,     hovertool_columns=["name:ru"],     title="Пустая карта РФ",     xlim=[20, 180],     ylim=[40, 80],  )

Анализ общего количества документов

Подготовим статистику для визуализации и объединим с данными о границах регионов:

_df = statistics_df.replace([np.inf, -np.inf], np.nan, inplace=False).dropna() _df_general = dt_dict["general_info"]["data_clean"] _df = _df.merge(_df_general, how="left", on="id") _df.head(3)  _df3 = (_df[["decisions_total","acceptions_total","rejections_total", "year", "att_region","iso_code"]] .groupby(["year", "iso_code"]) .sum())  # Пересчитаем доли на агрегатах _df3["rejections_share"] = (     _df3["rejections_total"] / _df3["decisions_total"] ) _df3["acceptions_share"] = (     _df3["acceptions_total"] / _df3["decisions_total"] )  annual_reg_stat = _df3.reset_index() display(annual_reg_stat.head(3))
reg_stat_2021 = annual_reg_stat.loc[annual_reg_stat["year"]==2021,] reg_stat_2021 = reg_stat_2021.replace("",np.nan).dropna() points_to_map = gdf_full_mercator.merge(reg_stat_2021, how="left", left_on="ISO3166-2", right_on="iso_code")   #Replace NaN values to string 'No data'. points_to_map.loc[:,["year","decisions_total","acceptions_total","rejections_total", "rejections_share"]].fillna('No data', inplace = True) points_to_map.head()
plot_total_counts = points_to_map.plot_bokeh(     figsize = (1000, 600),     simplify_shapes=20000,     hovertool_columns=["name:ru", "decisions_total","acceptions_total","rejections_total", "rejections_share"],     dropdown = ["decisions_total","acceptions_total","rejections_total"],     title="2021",     colormap="Viridis",     colorbar_tick_format="0.0a",     xlim=[20, 180],     ylim=[40, 80],     return_html=True,     show_figure=True, )  # Export the HTML string to an external HTML file and show it: with open("plot_total_counts.html", "w") as f:     f.write((r"""""" + plot_total_counts))

Интерактивный график доступен по ссылке

Анализ отказов

plot_rejections = points_to_map.plot_bokeh(     figsize=(1000, 600),     simplify_shapes=20000,     hovertool_columns=[         "name:ru",         "decisions_total",         "acceptions_total",         "rejections_total",         "rejections_share",     ],     dropdown=["rejections_share"],     title="2021",     colormap="Viridis",     colorbar_tick_format="0%",     xlim=[20, 180],     ylim=[40, 80],     return_html=True,     show_figure=True, )  # Export the HTML string to an external HTML file and show it: with open("plot_rejections.html", "w") as f:     f.write(r"""""" + plot_rejections)

Интерактивный график доступен по ссылке

Визуализируем изменение доли отказов по регионам во времени. Ранее мы определили, что отказы «поперли» только с 2019 года. Соответственно статистику отобразим с этого момента. Для этого агрегируем данные по годам/регионам и подготовим dataframe в wide формате для возможности отображения на географике.

display(annual_reg_stat.head()) statistics_df_wide = annual_reg_stat.pivot(index="iso_code", columns=["year",]) # Убираем мультииндекс и объединяем название колонок с годами statistics_df_wide.columns = ['_'.join((col[0],str(col[1]))) for col in statistics_df_wide.columns] statistics_df_wide.reset_index(inplace=True) statistics_df_wide.head()
# Replace NaN values to string 'No data'. statistics_df_wide.fillna('No data', inplace = True)  # Combine statistics with geodataframe history_to_map = gdf_full_mercator.merge(statistics_df_wide, how="left", left_on="ISO3166-2", right_on="iso_code")  #Specify slider columns: slider_columns = ["rejections_share_%d"%i for i in range(2019, 2022)]  #Specify slider columns: slider_range = range(2019, 2022)  plot_rejections_slider = history_to_map.plot_bokeh(     figsize = (1000, 600),     simplify_shapes=20000,     hovertool_columns=["name:ru"]+slider_columns,       slider=slider_columns,     slider_range=slider_range,     slider_name="Year",     title="Изменение доли отказов по регионам/годам",     colormap="Viridis",     colorbar_tick_format="0%",     xlim=[20, 180],     ylim=[40, 80],     return_html=True,     show_figure=True, )  # Export the HTML string to an external HTML file and show it: with open("plot_rejections_slider.html", "w") as f:     f.write(r"""""" + plot_rejections_slider)

Интерактивный график доступен по ссылке

В 2020 году лидером по доле отказов была Астраханская область. «Зарезано» 90% поданных документов. В 2021 году в лидеры вырывается Московская область с 73% отказов.

Цифры колоссальные, если учесть сколько труда стоит за каждым из документов:

  • как минимум, несколько часов работы кадастрового инженера;

  • сходить в МФЦ (в лучшем случае) и подать их;

  • время сотрудников Росреестра на формирование отказа.

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

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

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

Анализ работы индивидуальных инженеров

# Объединим датасеты статистики работы и общей информации по кад.инженерам kadeng_stat = statistics_df.copy() _df_general = dt_dict["general_info"]["data_clean"] kadeng_stat = kadeng_stat.merge(_df_general, how="left", on="id")  # Сгруппируем данные по кадастровым инженерам и годам kadeng_stat_agg = (kadeng_stat[["decisions_total","acceptions_total","rejections_total", "year","id", "att_region"]] .groupby(["att_region","id", "year"]) .sum())  # Пересчитаем доли на агрегатах kadeng_stat_agg["rejections_share"] = (     kadeng_stat_agg["rejections_total"] / kadeng_stat_agg["decisions_total"] ) kadeng_stat_agg["acceptions_share"] = (     kadeng_stat_agg["acceptions_total"] / kadeng_stat_agg["decisions_total"] )  kadeng_stat_agg.replace([np.inf, -np.inf], np.nan, inplace=True) kadeng_stat_agg = kadeng_stat_agg.reset_index(drop=False)
kadeng_stat_agg.loc[kadeng_stat_agg["year"] == 2021].describe()
decisions_total_hist = kadeng_stat_agg.loc[kadeng_stat_agg["year"] == 2021].plot_bokeh(     kind="hist",     bins = 100,     y=["decisions_total"],     xlim=(0, 3000),     vertical_xlabel=True,     show_average = True,     title = "РФ_2021: Количество поданных документов",     show_figure=False,  )  rejections_share_hist = kadeng_stat_agg.loc[kadeng_stat_agg["year"] == 2021].dropna().plot_bokeh(     kind="hist",     bins=np.arange(0, 3.5, 0.1),     y="rejections_share",     xlim=(0, 2),     vertical_xlabel=True,     show_average = True,     title = "РФ_2021: Доля отказов",     show_figure=False, )  plot_kad_eng_stat = pandas_bokeh.plot_grid([[decisions_total_hist, rejections_share_hist]], width=400, height=300, return_html=True,)   # Export the HTML string to an external HTML file and show it: with open("plot_kad_eng_stat.html", "w") as f:     f.write(r"""""" + plot_kad_eng_stat) 

Интерактивный график доступен по ссылке

В 2021 году средняя доля отказов в группировке по кадастровым инженерам составляет почти 25% — вдвое больше, чем доля отказов по суммарному количеству документов. Гипотеза: есть небольшое количество «супер-успешных» кадастровых инженеров, с большим количеством поданных документов, которые «проходят» на отлично и которые вытягивают среднюю статистику

Посмотрим аналогичную статистику по Московской области

def plot_hist_by_region(year, region_num, kadeng_stat_agg):      if isinstance(region_num, int):         region_num=str(region_num)      _decisions_total_hist = kadeng_stat_agg.loc[((kadeng_stat_agg["year"] == year) & (kadeng_stat_agg["att_region"] == region_num))].plot_bokeh(         kind="hist",         bins = 30,         y=["decisions_total"],         #xlim=(0, 3000),         vertical_xlabel=True,         show_average = True,         title = f"{region_num}_{year}: Количество поданных документов", #"РФ 2021: Количество поданных документов",         show_figure=False,     )      _rejections_share_hist = kadeng_stat_agg.loc[((kadeng_stat_agg["year"] == year) & (kadeng_stat_agg["att_region"] == region_num))].dropna().plot_bokeh(         kind="hist",         #bins=np.arange(0, 2.5, 0.1),         bins = 30,         y=["rejections_share"],         #xlim=(0, 2.5),         vertical_xlabel=True,         show_average = True,         title = f"{region_num}_{year}: Доля отказов",         show_figure=False,     )     return [_decisions_total_hist, _rejections_share_hist]  plots_list = plot_hist_by_region(2021, 50, kadeng_stat_agg) plot_kad_eng_stat_50 = pandas_bokeh.plot_grid([plots_list], width=400, height=300, return_html=True,)   # Export the HTML string to an external HTML file and show it: with open("plot_kad_eng_stat_50.html", "w") as f:     f.write(r"""""" + plot_kad_eng_stat_50)

Интерактивный график доступен по ссылке

Для жителей Московской области или москвичей, кто хотел бы решить земельные вопросы, статистика неутешительная. «Средний» кадастровый инженер получил в 2021 году 98% отказов.

Определим лидеров и аутсайдеров среди кадастровых инженеров. Дальнейший анализ сделаем для данных по Московской области за последние 3 года (2019-2021). Нас интересует рэнкинг по количеству документов (больше — лучше) и доле отказов (больше — хуже).

years = [2019, 2020, 2021] region_num = "50"  kadeng_stat_50 = kadeng_stat_agg.loc[((kadeng_stat_agg["year"].isin(years) ) & (kadeng_stat_agg["att_region"] == region_num))]  # Переведем в wide форму kadeng_stat_50_wide = kadeng_stat_50.pivot(index=["att_region", "id"], columns=["year",]) # Убираем мультииндекс и объединяем название колонок с годами kadeng_stat_50_wide.columns = ['_'.join((col[0],str(col[1]))) for col in kadeng_stat_50_wide.columns] kadeng_stat_50_wide.reset_index(inplace=True)  # Дополним данными ФИО и аттестата кад.инженера kadeng_stat_50_wide = kadeng_stat_50_wide.merge(general_info_clean, how="left", left_on="id", right_on="id") kadeng_stat_50 = kadeng_stat_50.merge(general_info_clean, how="left", left_on="id", right_on="id") display(kadeng_stat_50_wide.head()) display(kadeng_stat_50.head())
cols_to_plot = [     "decisions_total",     "acceptions_total",     "rejections_total",     "rejections_share",     "full_name",     "att_number",     "att_date",     "year", ]  #Use the field name of the column source mapper = linear_cmap(field_name='year', palette=Viridis3, low=2019 ,high=2021)  source = ColumnDataSource(data=kadeng_stat_50.loc[:, cols_to_plot])  TOOLTIPS = [     ("Год", "@year"),     ("ФИО", "@full_name"),     ("Всего решений", "@decisions_total"),     ("Доля отказов", "@rejections_share{(0%)}"),     ("Аттестат", "@att_number"), ]  p = figure(width=800, height=400, tooltips=TOOLTIPS, title="Количество решений Росреестра: всего и отказов",)  p.circle(     "rejections_total",     "decisions_total",     source=source,     color = mapper,     legend_group = "year" )  p.legend.location = "top_left" p.xaxis.axis_label = "Количество отказов" p.xaxis.formatter = NumeralTickFormatter(format='0') p.yaxis.axis_label = "Количество решений"  # Output to file / notebook reset_output() #output_file("plot_kad_eng_50_2019_2021.html") #save(p) output_notebook() show(p)

Интерактивный график доступен по ссылке

cols_to_plot = ["decisions_total", "acceptions_total", "rejections_total",  "rejections_share", "full_name", "att_number", "att_date", "year", ] kadeng_stat_50.loc[:,cols_to_plot]

Выбор лучших кад.инженеров по Московской области

Определим лучших кадастровых инженеров по Московской области

kadeng_stat_50_wide.head(3)  # Определим правила ренкинга: для колонок где "меньше-лучше" используем параметр ascending = True cols_to_rank = {     "decisions_total": False,     "acceptions_total": False,     "rejections_share": True, } years_to_rank = [2019, 2020, 2021]  # Функция создания колонок с рангом по разным показателям/годам def rank_kad_eng(df, cols_to_rank, years_to_rank):     for year in years_to_rank:         for col, asc_param in cols_to_rank.items():             df[f"rank_{col}_{year}"] = df[f"{col}_{year}"].rank(                 na_option="bottom",                 ascending=asc_param,                 method="min",             )     return df   kadeng_stat_50_ranked = rank_kad_eng(kadeng_stat_50_wide, cols_to_rank, years_to_rank)  # Определим интегральный взвешенный показатель, учитывающий активность и доли отказов # Веса определил исходя из своего видения важности критериев # Для своих целей я решил 2019 не использовать, его вес будет 0  ranking_weights = {     # Sum to 1     "years_weights": {         2019: 0,         2020: 0.25,         2021: 0.75,     },     # Sum to 1     "indicator_weights": {         "decisions_total": 0.25,         "rejections_share": 0.75,         "acceptions_total": 0,     }, }   def integral_rank(df, ranking_weights):     df["integral_rank"] = 0     for year, year_weight in ranking_weights.get("years_weights").items():         for indicator, ind_weight in ranking_weights.get("indicator_weights").items():             df["integral_rank"] = (                 df["integral_rank"]                 + df[f"rank_{indicator}_{year}"] * year_weight * ind_weight             )     return df  kadeng_stat_50_integral = integral_rank(kadeng_stat_50_ranked, ranking_weights)   display(     kadeng_stat_50_integral.sort_values(by="integral_rank").head(10).T )

Предварительные выводы

Статистика по Московской Области крайне печальная:

  • Росреестр отказывает по 3/4 поданных документов;

  • Среднестатистический кад. инженер в 2021 году в Московской области получил 98% отказов.

Я не буду размышлять и строить гипотезы о коррупциогенной природе такого уровня отказов. В любом случае, такие цифры выглядят, как минимум, как саботаж нормальной работы и перекладывание ответственности с Росреестра на Суды (куда вынуждены идти люди, которым надо решить земельные вопросы). Возникают сомнения в целесообразности существования структуры (включая немаленькие бюджеты, в том числе на ИТ и сотрудников), чья суть деятельности сводится к практически 100% отказу.

Допущения и ограничения анализа

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

Следующие шаги

ML/Статистика:

  • Анализ второй цифры в аттестате и ее значимости влияния на на эффективность деятельности;

  • Анализ домена почты кад.инженера и его влияния на эффективность деятельности;

  • Анализ даты выдачи аттестата и ее влияния на эффективность деятельности;

  • Анализ СРО по размеру и эффективности деятельности;

  • Анализ дисциплинарных взысканий и их влияния на эффективность деятельности кад.инженера.


ссылка на оригинал статьи https://habr.com/ru/articles/670760/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *