Как мы искали должников при помощи Pandas

от автора

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

Я занимаюсь анализом данных и дата-журналистикой в газете «Деловой Петербург». Расскажу о том, как объединяли информацию из множества локальных html-таблиц и приведу примеры кода на «Питоне».

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

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

Документы — это более 20 тыс. отчётов (по одному отчёту на каждый многоквартирный жилой дом в Петербурге), собранных в папки по восемнадцати районам города. Очевидно, это результат автоматической выгрузки из базы данных.

Часто журналисты не раскрывают свои источники. Но в этом случае данные открыты, хоть и расположены не в самом очевидном месте (вообще, опыт показывает: то, что прямо называется «открытыми данными», часто особого интереса обычно не представляет). Всё выглядит легальным: организация сама заявляет, что публикует эти документы на основании одного из приказов Минстроя.

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

Так выглядит отчёт по платежам в одном из тысяч многоквартирных домов Петербурга

Так выглядит отчёт по платежам в одном из тысяч многоквартирных домов Петербурга

Итак, у нас есть 18 папок, которые нужно пройти, в каждой более 1 тыс. документов, всё вместе содержит информацию о более чем 2 млн помещений (квартир и нежилых помещений). Автоматически прочитать таблицу из html-документа можно при помощи метода Pandas read_html(). Нам нужно прочитать их все и объединить содержащиеся в файлах таблицы.

Прямолинейное решение

Первое, что приходит в голову — объединить маленькие таблицы в большую средствами самой библиотеки Pandas:

#Это пример кода на языке Python 3 #В нём собираем нужную информацию из html-таблиц в одну базу данных  #Предполагается, что выгруженные с сервера каталоги с  #html-файлами дежат в папке data нашего проекта.  import pandas as pd #Наш главный помощник - библиотека Pandas.                      #Не забудьте установить её, если будете запускать этот код. from pathlib import Path #Для подсчёта числа файлов import os #Для прохода по списку файлов from tqdm import tqdm #Необязательно: используем, чтобы                       #следить за ходом выполнения программы.                        #Этот модуль тоже устанавливается отдельно.   dfconcat=pd.DataFrame() #Готовим пустую базу данных  path='Долги' #Путь к папке, где лежат файлы отчётов files_count=len(list(Path(path).rglob("*"))) #Подсчитаем общее число файлов  pbar=tqdm(total=files_count)  for foldername, subfolders, filenames in os.walk(path): #Проходим по всем подкаталогам     for filename in (filenames):                        #И берём каждый файл         filepath=os.path.join(foldername, filename)     #Получаем полный путь к файлу          try: #По очень небольшому числу домов нет данных о долгах, потому что              #их жители собирают взносы на отдельных счетах. Пропускаем эти дома.             df=pd.read_html(filepath, thousands='.', decimal=',') #Считываем таблицы                                                                   #из html-файла         except:             continue           #Каждый отчёт содержит несколько таблиц. Для нашего примера         #мы будем брать только строчки с долгами по каждому помещению в доме                debts=df[3].iloc[4:-2]          dfconcat=dfconcat._append (debts, ignore_index=True) #Присоединяем собранные                                                              #строки к единой таблице                                                              #Можно также                                                              #использовать похожий                                                              #метод concat                  pbar.update()          pbar.close()        print (dfconcat) #Выводим результат в командную строку

Вначале всё идёт хорошо, и ожидаемое время завершения формирования единой таблицы, как показывает индикатор полосы прогресса tqdm, составляет около 5 минут. Но с каждым присоединением промежуточной таблицы оно растёт. Когда индикатор хода выполнения задачи подходит к 20 %, прогнозируемое время выполнения её приближается уже к 30 минутам, а скорость падает с 30 до 8 итераций в секунду. Затем прогноз оставшегося времени и вовсе приближается к 2 часам.

Объединение таблиц средствами «Питона»

Причина, судя по всему, в медленной работе метода append в Pandas. Попробуем обойтись без него. Следующее решение, предполагающее двойное преобразование, я нашёл на Stack Owerflow. Подготовим пустой словарь-накопитель. Каждую маленькую таблицу, прочитанную из html-файла, будем преобразовывать в словарь и добавлять к словарю-накопителю. Затем уже преобразуем словарь-накопитель обратно в таблицу Pandas, чтобы можно было его анализировать.

from pathlib import Path  import pandas as pd import os from tqdm import tqdm   path='Долги'  files_count=len(list(Path(path).rglob("*")))  pbar=tqdm(total=files_count)  d = {} #Готовим словарь-накопитель i = 0 #Итератор for foldername, subfolders, filenames in os.walk(path):     for filename in (filenames):                                 filepath=os.path.join(foldername, filename)         try:              df=pd.read_html(filepath, thousands='.', decimal=',')          except:             continue                    debts=df[3].iloc[4:-2]          #Убираем лишние колонки, которые не несут смысловой нагрузки         debts.drop(debts.columns[[-1,-2,-3,-4,-5,-6]],axis=1,inplace=True)         #Дадим колонкам понятные названия          debts.columns=['Номер помещения', 'Задолженность на начало периода', 'Аванс на начало периода', 'Начислено нарастающим итогом', 'Уплачено нарастающим итогом', 'Засчитано за оказанные услуги', 'Задолженность на конец периода', 'Аванс на конец периода', 'Уплачено пени']                  data = debts.to_dict('records') #Преобразуем таблицу в словарь         for entry in data: #И добавляем каждую строчку к словарю-накопителю             d[i] = {"Номер помещения": entry["Номер помещения"], "Задолженность на начало периода": entry["Задолженность на начало периода"], "Аванс на начало периода":entry["Аванс на начало периода"],  "Начислено нарастающим итогом":entry["Начислено нарастающим итогом"], "Уплачено нарастающим итогом":entry["Уплачено нарастающим итогом"], "Засчитано за оказанные услуги":entry["Засчитано за оказанные услуги"], "Задолженность на конец периода":entry["Задолженность на конец периода"], "Аванс на конец периода":entry["Аванс на конец периода"], 'Уплачено пени':entry["Уплачено пени"]}             # increment the counter             i = i + 1         pbar.update()          pbar.close() dfconcat = pd.DataFrame.from_dict(d, "index") #Обратное преобразование из словаря                                               #в таблицу print (dfconcat)

Вышло около 13 минут, что значительно лучше. Но и этот способ оказался не самым быстрым.

Объединение таблиц через базу данных

Чуть быстрее на моём компьютере оказалось загружать извлечённую из html-файлов табличную информацию в базу данных. Хотя для работы c SQL базами данных из «Питона» часто используют библиотеку SQLAlchemy, здесь это не строго обязательно. Мы будем напрямую отправлять несложные SQL команды из Pandas во входящий в стандартный пакет поставки «Питона» модуль SQLite.

#Это пример кода похож на предыдущий, но таблицы  #из html-файлов объединяются при помощи SQLite  import pandas as pd         import os                                  import sqlite3 #На этот раз будем использовать SQL-базу для накопления данных  from pathlib import Path  from tqdm import tqdm   # Подключаем файл базы данных и создаём таблицу con=sqlite3.connect('est.db') cur=con.cursor() cur.execute("DROP TABLE IF EXISTS result;") #На всякий случай, если прошлый                                              #проход программы был прерван cur.execute("CREATE TABLE result (foldername, filename, num_of_ap, \             start_debt, start_avans, accrued, paid, counted, final_debt, \             final_avans, peni);")  path='Долги'  files_count=len(list(Path(path).rglob("*")))  pbar=tqdm(total=files_count)  #Эта функция вызывается для каждой строки таблицы, считанной Pandas, #и записывает эту строку в базу SQLite def addrows (row):      params=(foldername, filename, row['Номер помещения'],             row['Задолженность на начало периода'],              row['Аванс на начало периода'],              row['Начислено нарастающим итогом'],              row['Уплачено нарастающим итогом'],              row['Засчитано за оказанные услуги'],              row['Задолженность на конец периода'],              row['Аванс на конец периода'],              row['Уплачено пени'])       cur.execute('INSERT INTO result VALUES (?,?,?,?,?,?,?,?,?,?,?);', params)                   for foldername, subfolders, filenames in os.walk(path):     for filename in (filenames):                                 filepath=os.path.join(foldername, filename)          try:              df=pd.read_html(filepath, thousands='.', decimal=',')          except:             continue                  debts=df[3].iloc[4:-2]         debts.drop(debts.columns[[-1,-2,-3,-4,-5,-6]],axis=1,inplace=True)                  #Дадим понятные названия колонкам, чтобы потом записать по ним их в         #базу в функции addrows         debts.columns=['Номер помещения',                         'Задолженность на начало периода',                        'Аванс на начало периода',                        'Начислено нарастающим итогом',                        'Уплачено нарастающим итогом',                        'Засчитано за оказанные услуги',                        'Задолженность на конец периода',                        'Аванс на конец периода',                        'Уплачено пени']                  debts.apply(addrows, axis=1)         pbar.update()  pbar.close() dfconcat = pd.read_sql_query("SELECT * FROM result", con) print (dfconcat)

Выполнение этой программы заняло около 12 минут, что намного быстрее первоначального варианта, и чуть быстрее варианта со словарём. На моём компьютере она выполнялась со скоростью около 30 операций в секунду.

Результаты и выводы

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

В итоге, мы обнаружили, что чаще всего за капитальный ремонт не платит бизнес. То есть владельцы нежилых (коммерческих) помещений в многоквартирных жилых домах. Так как в центре Петербурга, по сравнению с другими частями нашего города, таких помещений больше всего, то и должников там значительно больше. По словам экспертов, если собственник не платит за капитальный ремонт, то и другие жилищно-коммунальные услуги он, скорее всего, не будет аккуратно оплачивать. Поэтому можно предполагать, что существенная часть долга за ЖКУ — это долги бизнеса.

Итоговый материал можно прочитать на сайте газеты.


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