Как стать автором
Обновить

Pandas в pandas'е: упаковываем документацию в датафрейм

Уровень сложностиСредний
Время на прочтение25 мин
Количество просмотров3.1K
pandas предоставляет большое количество симпатичных методов для обработки данных
pandas предоставляет большое количество симпатичных методов для обработки данных

Документация к сложным библиотекам на питоне (напр. pandas) хранится в doc-строках и разбросана по сотням страниц сайта. В этой статье мы с помощью небольшого кода упакуем её (информацию из документации для каждого класса и метода) в... датафрейм. Но зачем? Во-первых, это прикольно так её можно быстро искать и анализировать. Во-вторых, изучим некоторые встроенные питоновские средства работы с документацией. Наконец, такой датафрейм потенциально может стать основой для обучения/дообучения GPT-моделей генерировать более корректный, безошибочный, использующий всевозможные функции и их аргументы, код...

Переходя от текста, хоть и структурированного, к полностью формализованному табличному описанию, мы движемся в сторону неантропоморфности документации - то есть в сторону облегчения её понимания для машины, алгоритма, и, возможно, усложнения понимания для человека (не не всякого - тот, кто может оперировать датафреймами, извлечет из такой документации пользу намного быстрее и эффективнее! )

Используемая версия pandas - 2.1.4. Версия python 3.8.12.

Первый простой опыт упаковки документации в датафрейм

Сначала импортируем необходимые библиотеки:

import pandas as pd
import numpy as np

Для любого объекта, которым также является и сама библиотека pandas, импортированная под именем pd, можно использовать несколько способов посмотреть документацию или ее часть:

Далее, говоря о "методах pandas" мы будем говорить о "публично доступных объектах pandas - классах, функциях, методах классов, атрибутах, описанных в документации"

Попробуем сначала вызвать список аргументов и методов с помощью дандер-метода (т.е. специального метода, с двойными подчеркиваниями вначале и в конце) __dir__().

А с помощью if not x.startswith('_') мы исключим все внутренние переменные и дандер-методы:

[x for x in pd.__dir__() if not x.startswith('_')]
Получим очень много методов, но как их упорядочить???
['annotations',
 'core',
 'errors',
 'util',
 'compat',
 'get_option',
 'set_option',
 'reset_option',
 'describe_option',
 'option_context',
 'options',
 'pandas',
 'io',
 'tseries',
 'arrays',
 'plotting',
 'ArrowDtype',
 'Int8Dtype',
 'Int16Dtype',

...

 'merge_ordered',
 'crosstab',
 'pivot',
 'pivot_table',
 'get_dummies',
 'from_dummies',
 'cut',
 'qcut',
 'api',
 'testing',
 'show_versions',
 'ExcelFile',
 'ExcelWriter',
 'read_excel',
 'read_csv',
 'read_fwf',
 'read_table',
 'read_pickle',
 'to_pickle',
 'HDFStore',
 'read_hdf',
 'read_sql',
 'read_sql_query',
 'read_sql_table',
 'read_clipboard',
 'read_parquet',
 'read_orc',
 'read_feather',
 'read_gbq',
 'read_html',
 'read_xml',
 'read_json',
 'read_stata',
 'read_sas',
 'read_spss',
 'json_normalize',
 'test',
 'Float64Index',
 'Int64Index',
 'UInt64Index']

К сожалению, полученный список слишком большой и не дает нам понимания, как группируются эти методы. Поэтому воспользуемся прямым путём: откроем документацию по адресу https://pandas.pydata.org/docs/reference/index.html и обратим внимание на меню слева. Здесь перечислены все группы методов. Просто копипейстом занесем их в строку groups. Теперь мы можем превратить строку в список с помощью .splitlines() - метода, который по умолчанию разбивает строку, используя как разделитель перевод строки \n. А с помощью метода .strip() мы удаляем пробелы в начале и в конце строки:

Слева перечислены основные группы функций pandas
Слева перечислены основные группы функций pandas
groups = """Input/output
General functions
Series
DataFrame
pandas arrays, scalars, and data types
Index objects
Date offsets
Window
GroupBy
Resampling
Style
Plotting
Options and settings
Extensions
Testing"""

[x.strip() for x in groups.splitlines()]
['Input/output',
 'General functions',
 'Series',
 'DataFrame',
 'pandas arrays, scalars, and data types',
 'Index objects',
 'Date offsets',
 'Window',
 'GroupBy',
 'Resampling',
 'Style',
 'Plotting',
 'Options and settings',
 'Extensions',
 'Testing']

Теперь мы раскроем каждый из пунктов меню и по очереди перенесем их значения в строки. Первой создадим строку input_output, содержащую методы ввода-вывода данных в разных форматах:

input_output = '''pandas.read_pickle
    pandas.DataFrame.to_pickle
    pandas.read_table
    pandas.read_csv
    pandas.DataFrame.to_csv
    pandas.read_fwf
    pandas.read_clipboard
    pandas.DataFrame.to_clipboard
    pandas.read_excel
    pandas.DataFrame.to_excel
    pandas.ExcelFile
    pandas.ExcelFile.book
    pandas.ExcelFile.sheet_names
    pandas.ExcelFile.parse
    pandas.io.formats.style.Styler.to_excel
    pandas.ExcelWriter
    pandas.read_json
    pandas.json_normalize
    pandas.DataFrame.to_json
    pandas.io.json.build_table_schema
    pandas.read_html
    pandas.DataFrame.to_html
    pandas.io.formats.style.Styler.to_html
    pandas.read_xml
    pandas.DataFrame.to_xml
    pandas.DataFrame.to_latex
    pandas.io.formats.style.Styler.to_latex
    pandas.read_hdf
    pandas.HDFStore.put
    pandas.HDFStore.append
    pandas.HDFStore.get
    pandas.HDFStore.select
    pandas.HDFStore.info
    pandas.HDFStore.keys
    pandas.HDFStore.groups
    pandas.HDFStore.walk
    pandas.read_feather
    pandas.DataFrame.to_feather
    pandas.read_parquet
    pandas.DataFrame.to_parquet
    pandas.read_orc
    pandas.DataFrame.to_orc
    pandas.read_sas
    pandas.read_spss
    pandas.read_sql_table
    pandas.read_sql_query
    pandas.read_sql
    pandas.DataFrame.to_sql
    pandas.read_gbq
    pandas.read_stata
    pandas.DataFrame.to_stata
    pandas.io.stata.StataReader.data_label
    pandas.io.stata.StataReader.value_labels
    pandas.io.stata.StataReader.variable_labels
    pandas.io.stata.StataWriter.write_file'''

[x.strip() for x in input_output.splitlines()]

... и сделаем это для остальных групп.

Как вариант, можно было бы написать парсер сайта документации pandas, но это заняло бы намного больше времени... Но парсер пригодился бы, если бы мы захотели сохранить историю всех версий pandas.

Теперь создадим словарь, в котором ключами groups_keys будут названия групп методов, а значениями groups_items - списки методов.

В groups_str_items поместим список наших строк, каждая из которых содержит методы той или иной группы, создадим список списков groups_items и с помощью функции zip объединим каждый ключ с соответствующим списком значений в словарь groups_dict:

groups_keys = [x.strip() for x in groups.splitlines()]

groups_str_items = [input_output, general_functions, series, dataframe, 
                    arrays_scalars_data_types, index_objects, date_ofsets, 
                    window, groupby, resampling, style, plotting, 
                    options_settings, extensions, testing,]

groups_items = []
for item in groups_str_items:
    group_item = []
    for x in item.splitlines():
        group_item.append(x.strip())
    groups_items.append(group_item)

groups_dict = {key: value for key, value in zip(groups_keys, groups_items)}

Отлично, теперь мы можем создать простейший датафрейм, который позволит посчитать первичную статистику методов pandas. У нас будут 2 столбца: 'group' - название группы методов и 'method' - собственно, сам метод с указанием родительских классов (для удобства).

Методом concat мы можем объединять несколько датафреймов в один (в данном случае - построчно), ignore_index=True означает, что существующие индексы игнорируются, итоговый индекс формируется заново, чтобы был сквозной индекс:

prime_df = pd.DataFrame(columns=['group', 'method'])
for key, item_list in groups_dict.items():
    df = pd.DataFrame()
    df['method'] = item_list
    df['group'] = key
    prime_df = pd.concat([prime_df, df], ignore_index=True)

print(prime_df)
2085 методов обнаружили мы в pandas версии 2.1.4. Однако!
2085 методов обнаружили мы в pandas версии 2.1.4. Однако!

Итак, pandas в версии 2.1.4 насчитывает 2085 методов! Выведем первичную статистику по группам методов. Для этого нужно применить к датафрейму всего три метода pandas:

  • groupby() с указанием столбца, по которому делаем группировку - в данном случае это столбец группы методов;

  • agg() - агрегировать данные группы, в данном случае мы используем 'count' - подсчет количества;

  • sort_values() - отсортировать по данным столбца (в нашем случае 'method', содержащий количество методов в каждой группе), по убыванию ascending=False:

first_grouped_df = prime_df.groupby('group').agg('count').sort_values('method',ascending=False)
first_grouped_df
Самая большая группа содержит 662 метода, самая маленькая - 6.
Самая большая группа содержит 662 метода, самая маленькая - 6.

Проще всего выгрузить данные в .csv, который можно открыть в Excel или другими электронными таблицами и даже загрузить в базы данных. Выгрузим без индексов, разделителем будет запятая. Создадим для этого папку Data в текущем каталоге.

# В Data/.csv , разделитель по умолчанию ','
prime_df.to_csv('Data/prime_df.csv', 
                index=False)
first_grouped_df.to_csv('Data/first_grouped_df.csv',
                index=True)

Полученные файлы можно скачать: prime_df.csv, first_grouped_df.csv

Второй уровень группировки методов

Расширим нашу информацию: оказывается, внутри многих больших групп содержатся группы помельче! Например, раскрыв группу Input/output, мы справа увидим еще множество подгрупп второго уровня:

Да тут этих подгрупп просто залежи какие-то!
Да тут этих подгрупп просто залежи какие-то!
Занесем их в переменную input_output_2 (на выходе будем иметь список с этим именем):
input_output_2 = """Pickling
Flat file
Clipboard
Excel
JSON
HTML
XML
Latex
HDFStore: PyTables (HDF5)
Feather
Parquet
ORC
SAS
SPSS
SQL
Google BigQuery
STATA"""

input_output_2 = [x.strip() for x in input_output_2.splitlines()]

И так сделаем с каждой большой группой.

А теперь нужно создать список, где есть первый метод "большой" группы, а также последний метод каждой подгруппы второго уровня.

Так, для input_output это будет:
input_output_2_methods = """read_pickle
DataFrame.to_pickle
read_fwf
DataFrame.to_clipboard
ExcelWriter
build_table_schema
Styler.to_html
DataFrame.to_xml
Styler.to_latex
HDFStore.walk
DataFrame.to_feather
DataFrame.to_parquet
DataFrame.to_orc
read_sas
read_spss
DataFrame.to_sql
read_gbq
StataWriter.write_file"""

input_output_2_methods = [x.strip() for x in input_output_2_methods.splitlines()]

Проделаем эту копипейст-операцию со всеми подгруппами. (Да, как вариант, можно было бы написать парсер сайта - но это заняло бы больше времени...)

Объединим все данные о малых группах в словарь, где ключ - группа, а значение - кортеж из списка малых групп и списка граничных методов.

Вот такой словарь:
groups_to_small_groups = {  'Input/output': (input_output_2, input_output_2_methods),
                            'General functions': (general_functions_2, general_functions_2_methods),
                            'Series': (series_2, series_2_methods),
                            'DataFrame': (dataframe_2, dataframe_2_methods),
                            'pandas arrays, scalars, and data types': (arrays_scalars_data_types_2, arrays_scalars_data_types_2_methods),
                            'Index objects': (index_objects_2, index_objects_2_methods),
                            'Date offsets': (date_ofsets_2, date_ofsets_2_methods),
                            'Window': (window_2, window_2_methods),
                            'GroupBy': (groupby_2, groupby_2_methods),
                            'Resampling': (resampling_2, resampling_2_methods),
                            'Style': (style_2, style_2_methods),
                            'Plotting': (plotting_2, plotting_2_methods),
                            'Options and settings': (options_settings_2, options_settings_2_methods),
                            'Extensions': (extensions_2, extensions_2_methods),
                            'Testing': (testing_2, testing_2_methods),
                          }

Теперь напишем код, который добавит в наш датафрейм еще один столбец small_group, в котором будет метод второго уровня. Он перебирает строки датафрейма, и для каждого метода каждой группы находит соответствующую ему малую группу из нашего словаря groups_to_small_groups (учитывая массу тонкостей, поэтому такое количество разных условий и проверок):

Код добавления в датафрейм признака малой группы:
prime_df['small_group'] = None   # Заведем столбец для малых групп

counter = 0   # Это счетчик малых групп внутри списка малых групп
methods_counter = 0    # Это счетчик методов внутри списка методов
prev_group = None      # Предыдущая основная группа
small_group_now = None  # Малая группа в данный момент
small_groups_method_now_2 = None   # Граничный метод в данный момент
is_same = False  # Второй метод списка методов такой же, как и первый

for i, row in prime_df.iterrows():   # Перебираем по порядку все строки датафрейма
    small_groups_group = groups_to_small_groups[row.group][0]   # Список малых групп данной группы
    small_groups_method = groups_to_small_groups[row.group][1]  # Граничные методы данной группы

    if row.group != prev_group:  # Группа сменилась?
        counter = 0              # Значит, счетчик малых групп на ноль
        methods_counter = 0      # И счетчик граничных методов на ноль

    pure_method = row.method.split('.')[-1]  # Крайний слева метод
    pure_method_2 = f"{row.method.split('.')[-2]}.{row.method.split('.')[-1]}"    # 2 крайних слева метода в датафрейме

    small_groups_method_now = small_groups_method[methods_counter].split('.')[-1]  # Текущий малый метод
    small_groups_method_len = len(small_groups_method[methods_counter].split('.')) # Длина в списке методовlit('.'))
    if small_groups_method_len > 1:  # Если можем захватить 2 крайних класса метода - захватываем!
        small_groups_method_now_2 = f"{small_groups_method[methods_counter].split('.')[-2]}.{small_groups_method[methods_counter].split('.')[-1]}" 

    # Совпадает метод? - значит мы поймали конец малой группы!
    if small_groups_method_len > 1 and pure_method_2 == small_groups_method_now_2:  # У 2 крайних классов
        is_same = True
    elif small_groups_method_len == 1 and pure_method == small_groups_method_now:  # Или хотя бы у одного
        is_same = True
    else:
        is_same = False
    
    if is_same:
        if methods_counter == 0:  # Это открывающий метод?
            small_group_now = small_groups_group[counter]   # Текущая малая группа
            row['small_group'] = small_group_now  # Запишем в датафрейм
            
            # Проверка на дубль: если следующий такой же:
            if len(small_groups_group) > 1:  # В списке есть следующая малая группа
                if small_groups_method_len > 1:  # Если можем захватить метод с классом - захватываем!
                    if len(small_groups_method[methods_counter+1].split('.')) > 1: # Длина в списке методов 
                        sgm_next_2 = f"{small_groups_method[methods_counter + 1].split('.')[-2]}.{small_groups_method[methods_counter + 1].split('.')[-1]}"  # Следующий граничный метод с классом
                    
                if small_groups_method_len > 1 and sgm_next_2 == small_groups_method_now_2:
                    is_double = True   # Закрывающий дублирует открывающий
                elif small_groups_method_len == 1 and small_groups_method[methods_counter + 1].split('.')[-1] == small_groups_method_now: 
                    is_same = True
                else:
                    is_same = False
                if is_same:
                    counter += 1  # Увеличиваем счетчик малых групп
                    methods_counter += 1   # Увеличиваем счетчик граничных методов - пропускаем дубль
                    small_group_now = small_groups_group[counter]   # Малая группа сейчас

            methods_counter += 1   # Увеличиваем счетчик граничных методов
        else:   # Это закрывающий метод - переходим на следующую малую группу
            row['small_group'] = small_group_now   # Запишем в датафрейм
            counter += 1  # Увеличим счетчик малых групп
            if len(small_groups_group) == counter:   # Мы достигли конца списка малых групп?
                continue
            small_group_now = small_groups_group[counter]    # Малая группа сейчас 
            methods_counter += 1   # Увеличиваем счетчик граничных методов
    else:
        row['small_group'] = small_group_now   # Запишем в датафрейм

    prev_group = row.group   # Запоминаем предыдущую группу

Упорядочим группы (основная группа, подгруппа и метод) и посмотрим, что у нас получилось:

prime_df = prime_df[['group', 'small_group', 'method']]
prime_df
Те же 2085 строк, только еще и признак малой группы для лучшего поиска появился...
Те же 2085 строк, только еще и признак малой группы для лучшего поиска появился...

Метод info() позволяет вывести информацию о столбцах, и особенно важно - нет ли там нулевых значений? У нас нет. Тип object - это строковый тип в нашем случае.

prime_df.info()
Ок, все 2085 значений в каждом столбце ненулевые
Ок, все 2085 значений в каждом столбце ненулевые

Сохраним в файл prime_small_groups_df.csv

Статистика по основным и малым группам:

stat = prime_df.groupby(['group', 'small_group']).agg({'method': 'count'}).reset_index()
stat
Отлично, у нас теперь каждый метод имеет признак основной группы и признак малой группы, которых 256!
Отлично, у нас теперь каждый метод имеет признак основной группы и признак малой группы, которых 256!

Отсортируем по размеру малой группы (ignore_index=True означает сбросить индексы, т.е. отнумеровать их от 0):

stat_sort = stat.sort_values('method',ascending=False, ignore_index=True)
stat_sort
Мы видим, что некоторые малые группы / подгруппы содержат 40-56 методов! Но масса групп, в которых по одному методу...
Мы видим, что некоторые малые группы / подгруппы содержат 40-56 методов! Но масса групп, в которых по одному методу...

Выложим отсортированные данные в папку, файл можно скачать: stat_sort.csv

stat_sort.to_csv('Data/stat_sort.csv', 
                index=False)

Как получать данные из строки документации __doc__ и некоторых дандер-методов

Рассмотрим первый же метод в нашем датафрейме - read_pickle

Он у нас в формате строки. Чтобы получить метод из его названия, вызовем через getattr:

read_pickle = getattr(pd, 'read_pickle')
print(read_pickle.__doc__)
Получим очень длинный текст строки документации:
Load pickled pandas object (or any object) from file.
.. warning::
   Loading pickled data received from untrusted sources can be
   unsafe. See `here <https://docs.python.org/3/library/pickle.html>`__.
Parameters
----------
filepath_or_buffer : str, path object, or file-like object
    String, path object (implementing ``os.PathLike[str]``), or file-like
    object implementing a binary ``readlines()`` function.
    .. versionchanged:: 1.0.0
       Accept URL. URL is not limited to S3 and GCS.
compression : str or dict, default 'infer'
    For on-the-fly decompression of on-disk data. If 'infer' and 'filepath_or_buffer' is
    path-like, then detect compression from the following extensions: '.gz',
    '.bz2', '.zip', '.xz', '.zst', '.tar', '.tar.gz', '.tar.xz' or '.tar.bz2'
    (otherwise no compression).
    If using 'zip' or 'tar', the ZIP file must contain only one data file to be read in.
    Set to ``None`` for no decompression.
    Can also be a dict with key ``'method'`` set
    to one of {``'zip'``, ``'gzip'``, ``'bz2'``, ``'zstd'``, ``'tar'``} and other
    key-value pairs are forwarded to
    ``zipfile.ZipFile``, ``gzip.GzipFile``,
    ``bz2.BZ2File``, ``zstandard.ZstdDecompressor`` or
    ``tarfile.TarFile``, respectively.
    As an example, the following could be passed for Zstandard decompression using a
    custom compression dictionary:
    ``compression={'method': 'zstd', 'dict_data': my_compression_dict}``.
        .. versionadded:: 1.5.0
            Added support for `.tar` files.
    .. versionchanged:: 1.4.0 Zstandard support.
storage_options : dict, optional
    Extra options that make sense for a particular storage connection, e.g.
    host, port, username, password, etc. For HTTP(S) URLs the key-value pairs
    are forwarded to ``urllib.request.Request`` as header options. For other
    URLs (e.g. starting with "s3://", and "gcs://") the key-value pairs are
    forwarded to ``fsspec.open``. Please see ``fsspec`` and ``urllib`` for more
    details, and for more examples on storage options refer `here
    <https://pandas.pydata.org/docs/user_guide/io.html?
    highlight=storage_options#reading-writing-remote-files>`_.
    .. versionadded:: 1.2.0
Returns
-------
unpickled : same type as object stored in file
See Also
--------
DataFrame.to_pickle : Pickle (serialize) DataFrame object to file.
Series.to_pickle : Pickle (serialize) Series object to file.
read_hdf : Read HDF5 file into a DataFrame.
read_sql : Read SQL query or database table into a DataFrame.
read_parquet : Load a parquet object, returning a DataFrame.
Notes
-----
read_pickle is only guaranteed to be backwards compatible to pandas 0.20.3
provided the object was serialized with to_pickle.
Examples
--------
>>> original_df = pd.DataFrame(
...     {"foo": range(5), "bar": range(5, 10)}
...    )  # doctest: +SKIP
>>> original_df  # doctest: +SKIP
   foo  bar
0    0    5
1    1    6
2    2    7
3    3    8
4    4    9
>>> pd.to_pickle(original_df, "./dummy.pkl")  # doctest: +SKIP
>>> unpickled_df = pd.read_pickle("./dummy.pkl")  # doctest: +SKIP
>>> unpickled_df  # doctest: +SKIP
   foo  bar
0    0    5
1    1    6
2    2    7
3    3    8
4    4    9

Разберём, что у нас есть в строковой переменной документации?

  • Первая строка - это краткое описание метода: Load pickled pandas object (or any object) from file.

  • У нас есть разделы строки документации (они подчеркнуты символами "-"), например, Parameters, Returns, See Also, Notes, Examples

  • Различные сноски, начинающиеся с "..", например, .. warning:: , .. versionchanged::

  • В параметрах (аргументах) метода (Parameters) каждый параметр начинается без пробелов вначале, а его описание - с 4 пробелами.

  • В описании параметра после двоеточия приведены его возможные типы, например: str, path object, or file-like object, а также если есть значение по умолчанию, оно тоже приведено, например: default 'infer'

  • Аналогично, в выводимых функцией значениях: Returns

Мы также можем выжать из дандер-методов функции некоторую информацию, которая будет помогать нам разбирать (парсить) сроку документации для каждого метода (разберем первый из них, read_pickle):

С помощью __annotations__ мы получим словарь, в котором ключами являются аргументы метода, а значениями - типы переменных (строка, в которой через разделитель "|" перечислены типы):

read_pickle = getattr(pd, 'read_pickle')
print(read_pickle.__annotations__)
{'filepath_or_buffer': 'FilePath | ReadPickleBuffer',
 'compression': 'CompressionOptions',
 'storage_options': 'StorageOptions'}

Некоторые аргументы метода имеют значения по умолчанию, их кортеж можно вывести с помощью __defaults__:

read_pickle = getattr(pd, 'read_pickle')
print(read_pickle.__defaults__)
('infer', None)

Как видим, это два последних аргумента, поскольку вначале в питоновских методах и функциях идут атрибуты, которые не имеют значений по умолчанию, а потом - имеющие.

Еще мы можем получить путь к модулю метода с помощью __module__:

read_pickle = getattr(pd, 'read_pickle')
print(read_pickle.__module__)
'pandas.io.pickle'

С помощью __class__ мы можем вывести важную характеристику для сортировки. Так, методы будут обозначены как 'function' - ведь метод это и есть функция в классе:

read_pickle = getattr(pd, 'read_pickle')
print(read_pickle.__class__)
function

А вот класс DataFrame будет обозначен как 'type':

dataframe = getattr(pd, 'DataFrame')
print(dataframe.__class__)
type

Наконец, атрибут будет обозначен как 'property' или ссылкой на более сложный объект:

series_values = getattr(pd.Series, "values")
print(series_values.__class__)
property
series_index = getattr(pd.Series, "index")
print(series_index.__class__)
pandas._libs.properties.AxisProperty

Все эти параметры пригодятся нам в нашем датафрейме - своеобразной "базе данных" методов, классов и свойств pandas. Добавим в наш датафрейм еще несколько столбцов:

  • name - имя метода (без родительских классов)

  • type - определяет, это метод (function), класс (type) или свойство (property)

  • module - путь к модулю метода

  • doc - строка документации (вызываемая через __doc__)

  • about - краткое описание метода (первая строка документации)

  • parameters - список аргументов / атрибутов методов

  • annotations - словарь {атрибут: строка типов}

  • defaults - словарь {атрибут: значение по умолчанию}

Напишем код, который добавит в наш датафрейм новые столбцы:
def get_next_attr(class_, attr: str):
    """Возвращает ссылку на метод класса или None, если такого метода нет"""

    try:
        return getattr(class_, attr)
    except:
        return None


def parse_type(type_str:str) -> str:
    """Получает название метода/функции/класса, возвращает очищенным"""

    return type_str.replace("<class '", '').replace("'>", '')


prime_df['name'] = None   # имя метода / класса / свойства
prime_df['type'] = None   # определяет, это метод (function), класс (type) или свойство (property)
prime_df['module'] = None   # путь к модулю метода
prime_df['doc'] = None   # строка документации
prime_df['about'] = None   # краткое описание метода (первая строка документации)
prime_df['parameters'] = None   # список аргументов / атрибутов методв
prime_df['annotations'] = None   #  словарь атрибут: строка типов
prime_df['defaults'] = None   # словарь атрибут: значение по умолчанию

for i, row in prime_df.iterrows():
    len_name = row['method'].split('.')
    method_name = row['method'].split('.')[-1]
    prev_class = row['method'].split('.')[-2]
    middle = row['method'].split('.')[1:-1]
    method_path = row['method'].split('.')[1:]

    metod_object = pd
    for j, item in enumerate(row['method'].split('.')):
        metod_object = get_next_attr(metod_object, item)
        if metod_object is None:
            break
    
    row['name'] = method_name
    
    if metod_object is not None:
        try:
            row['type'] = f"{parse_type(str(metod_object.__class__))}"
        except AttributeError:
            pass

        try:
            row['module'] = metod_object.__module__
        except AttributeError:
            pass

        try:
            if metod_object.__doc__.startswith('\n'):
                row['doc'] = metod_object.__doc__[1:]
            else:
                row['doc'] = metod_object.__doc__
        except AttributeError:
            pass

        try:
            if len(metod_object.__doc__.split('\n')) > 1 \
                    and not metod_object.__doc__.split('\n')[0]:
                row['about'] = metod_object.__doc__.split('\n')[1]  #splitlines()[0]
        except AttributeError:
            pass

        try:
            row['parameters'] = metod_object.__annotations__.keys()
        except AttributeError:
            pass    

        try:
            row['annotations'] = metod_object.__annotations__
        except AttributeError:
            pass
        
        try:
            if metod_object.__annotations__:
                len_annotations = len(metod_object.__annotations__.keys())
            else:
                len_annotations = None

            if metod_object.__defaults__:
                len_defaults = len(metod_object.__defaults__)
            else:
                len_defaults = 0

            if len_annotations is not None and len_defaults:
                n_skip = len_annotations - len_defaults
                row['defaults'] = dict(zip(list(metod_object.__annotations__.keys())[n_skip:], metod_object.__defaults__))
        except AttributeError:
            pass

Посмотрим, что у нас получилось (итоговый файл в csv):

prime_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 2085 entries, 0 to 2084
Data columns (total 11 columns):
 #   Column       Non-Null Count  Dtype 
---  ------       --------------  ----- 
 0   group        2085 non-null   object
 1   small_group  2085 non-null   object
 2   method       2085 non-null   object
 3   name         2085 non-null   object
 4   type         1992 non-null   object
 5   module       1060 non-null   object
 6   doc          1693 non-null   object
 7   about        1675 non-null   object
 8   parameters   947 non-null    object
 9   annotations  947 non-null    object
 10  defaults     421 non-null    object
dtypes: object(11)
memory usage: 179.3+ KB

Итак, у нас по-прежнему 2085 методов, но вот получить ссылку на метод (и определить его тип - класс, функция или атрибут), применяя "в лоб"getattr() удалось только 1992 раза. Методы вроде pandas.io.formats.style.Styler.to_excel или pandas.arrays.NumpyExtensionArray оказались "крепкими орешками".

Если вам удастся написать код, который позволит вызвать __class__ и __doc__ у списка из 93 нераспознанных методов, напишите в комментариях. Это будет воистину python_senior_code !

Также мы видим, что строка документации есть у 1675 методов, не считая 93 не распознанных, поскольку атрибуты не имеют строки документации. Параметры (атрибуты) есть у 947 классов, методов и функций.

И, наконец, еще один момент: info() выдает нам размер датафрейма - memory usage: 179.3+ KB. Не маловато ли - ведь csv-файл весит 2,8 Мб?

Чтобы определить реально занимаемую датафреймом память, вызовем memory_usage() с параметром deep=True (искать глубоко по всем ссылкам):

prime_df.memory_usage(deep=True)
Index              128
group           145811
small_group     170679
method          185840
name            136464
type            159729
module          108671
doc            2610492
about           201014
parameters       57616
annotations     230696
defaults        147568
dtype: int64

Выясним, что итоговый объём занимаемой датафреймом памяти 4,15 Мб:

prime_df.memory_usage(deep=True).sum()
4154708

Структура итогового, расширенного датафрейма

Теперь мы изменим формат нашего датафрейма. До сего момента у нас строками датафрейма являлись методы (а также классы, функции, параметры), а столбцами - их типовые свойства (группа, класс, строка документации, список аргументов). Но, согласитесь, эта информация пока недостаточно детализирована. Нам нужно иметь более гибкую "базу данных", где каждой строкой было бы то или иное свойство того или иного метода/класса/атрибута pandas .

Давайте посмотрим, какие вообще разделы существуют в строке документации (разделы подчеркнуты символами тире). А также - какие виды примечаний существуют? Для этого переберем наш датафрейм, и выведем множество всех разделов и множество всех примечаний.

Создадим множество (set) всех разделов строки документации. Раздел документации подчеркнут (на следующей строке) тире '-' и начинается с начала строки. Раздел продолжается до названия следующего раздела

Также создадим множество (set) примечаний документации - они содержат вначале пробелы и начинаются с двойных точек '..' (но нужно исключить случаи тройных точек '...' - с них начинается строка вывода в примере!) и заканчивается двойными двоеточиями: '::' Информация примечаний начинается с :: и продолжается до следующего примечания .. или названия следующего раздела.

Код, возвращающий множество всех разделов документации и множество примечаний
def get_uniq_sections_notes(element: str) -> tuple:
    """Принимает строку - название метода pandas, возвращает кортеж множеств разделов и примечаний документации"""

    uniq_sections = set()
    uniq_notes = set()

    if element is None:
        return uniq_sections, uniq_notes
    
    lines = element.splitlines()
    prev_line = ''

    for line in lines:
        if line.startswith('--'):
            uniq_sections.add(prev_line)
            continue
        ind_end = -1
        if line.strip().startswith('..') and not line.strip().startswith('...'):
            note_line = line.strip().replace('..','').strip()
            ind_end = note_line.find('::')
        if ind_end != -1:
            note_line = note_line[:ind_end].strip()
            uniq_notes.add(note_line)
        prev_line = line.strip()

    return uniq_sections, uniq_notes

uniq_sections = set()
uniq_notes = set()
for value in prime_df['doc'].values:
    uniq_sections_new, uniq_notes_new = get_uniq_sections_notes(value)
    uniq_sections = uniq_sections | uniq_sections_new
    uniq_notes = uniq_notes | uniq_notes_new

Итак, уникальные разделы документации (два первых исключаем - это не разделы, они встречаются только в примерах):

uniq_sections
{'#   Column     Non-Null Count  Dtype',
 '#   Column    Non-Null Count    Dtype',
 'Attributes',
 'Examples',
 'Methods',
 'Notes',
 'Parameters',
 'Raises',
 'Returns',
 'See Also',
 'Yields'}

Уникальные метки примечаний документации:

uniq_notes
{'code-block',
 'deprecated',
 'math',
 'note',
 'plot',
 'versionadded',
 'versionchanged',
 'warning'}

Отлично, теперь мы можем описать структуру расширенного датафрейма - "базы данных" методов pandas.

Создадим датафрейм, в котором каждая строка - это отдельный элемент описания (документации) какого-либо метода. Используя один (!) такой датафрейм, мы быстро сможем извлечь различную необходимую информацию или статистику. Здесь нам понадобится лишь три универсальных столбца:

  • object (в него мы переименуем method) - название функции/метода/класса, содержащее родительские объекты и начинающееся на pandas.

  • element_type - вид элемента описания, ниже мы разберем все варианты

  • element_value - значение элемента описания

pandas_doc_df = pd.DataFrame(columns=['object', 'element_type', 'element_value'])

Такие таблицы называются melted - расправленные. Данные из множества столбцов как бы "стекли" в строки...

Какие же типы элементов описания каждой функции мы введём? Во-первых, те, которые мы уже выбрали ранее:

  • group - группа в документации

  • small_group - малая группа в документации

  • name - имя объекта, может дублироваться

  • type - описание типа (класс, функция, свойство),

  • module - модуль

  • doc - большая строка документации

  • about - описание функции

  • parameters - список атрибутов

  • annotations - словарь {атрибут: типы}. Типы представляют строку, разделенную '|'

  • defaults - словарь {атрибут: значение по умолчанию}

Но кроме этих типов элементов, мы введем дополнительно:

  • about_detailed - более детальное описание с первой строки до следующего раздела, как правило, следующий раздел Parameters. Включая примечания, начинающиеся с '..'

  • parameter_types_default_str - словарь {параметр: строка, содержащая варианты типов}, а также тип по умолчанию (часть строки после названия параметра). Каждая строка датафрейма - отдельный параметр.

  • parameter_doc_dict - словарь {параметр: строка, содержащая все данные до следующего параметра}. Каждая строка датафрейма - отдельный параметр.

  • returns_str - строка описания до следующего раздела

  • attributes_dict - словарь, содержащий {атрибут: строка описания}. Каждая строка датафрейма - отдельный атрибут.

  • methods_dict - словарь, содержащий {атрибут: строка описания}. Каждая строка датафрейма - отдельный атрибут.

  • notes - строка раздела Примечание

  • examples - строка раздела с примерами

  • yields - раздел генерируемых значений

  • raises - строка раздела исключения

  • see_also - строка раздела См.также

Мы можем создать список из 3 значений строки датафрейма, сведя их в итоговый "список списков", а потом сразу загрузить такой список в датафрейм. Переберем все строки в prime_df и вытащим данные из всех столбцов, сформировав по каждому строку для датафрейма. Далее возьмем строку документации __doc__ и распарсим её, сформировав еще несколько строк, по числу найденных элементов документации.

Напишем код для формирования такого датафрейма:
doc_data = []

def append_element(metod, element_type):
    """Добавляет в список doc_data список [metod, element_type, element_value]"""

    if row[element_type] is not None:
        element_value = row[element_type]
        doc_data.append([metod, element_type, element_value])


for i, row in prime_df.iterrows():
    metod = row['method']
    
    # Добавим уже известные нам данные
    for element_type in ['group', 'small_group', 'name', 'type', 
                         'module', 'doc', 'about', 'parameters', 
                         'annotations', 'defaults']:
        append_element(metod, element_type)

    # Парсим детализованное описание функции (до первого иного раздела в uniq_sections)
    element_type = 'about_detailed'
    if row['doc'] is not None:
        doc_lines = row['doc'].splitlines()
        element_value = ''
        for j, doc_string in enumerate(doc_lines):
            if j < len(doc_lines)-1 and doc_lines[j+1].strip() in uniq_sections:
                break
            else:
                element_value = f"{element_value}\n{doc_string}"

        doc_data.append([metod, element_type, element_value])

    # Парсим параметры метода/фукции и их описание
    element_type = 'parameter_types_default_str'
    if row['doc'] is not None:
        # Сформируем список строк с параметрами param_strings
        element_value = None
        param_strings = []
        is_param = False
        for j, doc_string in enumerate(doc_lines):
            if doc_string.strip() == 'Parameters':
                is_param = True
            if doc_string.strip() in (uniq_sections - {'Parameters'}):
                break
            if is_param:
                param_strings.append(doc_string)

        # Сформируем список строк с параметрами param_strings
        if param_strings:
            param_doc = ''
            for param_string in param_strings[2:]:
                try:
                    in_list = param_string.split(' : ')[0].strip() in list(row['parameters'])
                except:   
                    in_list = False
                if not param_string.startswith(' ') and ' : ' in param_string and in_list:
                    if param_doc != '':
                        element_type = 'parameter_doc_dict'
                        element_value = {element_value_key: param_doc}
                        doc_data.append([metod, element_type, element_value])
                    element_type = 'parameter_types_default_str'
                    element_value_key = param_string.split(':')[0].strip()
                    element_value_item = param_string.split(':')[1].strip()
                    element_value = {element_value_key: element_value_item}
                    doc_data.append([metod, element_type, element_value])
                    param_doc = f"{element_value_key}: {element_value_item}\n"
                    continue
                param_doc = f"{param_doc}\n{param_string}"

            element_type = 'parameter_doc_dict'
            element_value = {element_value_key: param_doc}
            doc_data.append([metod, element_type, element_value])

    # Парсим данные, возвращаемые методом:
    element_type = 'returns_str'
    if row['doc'] is not None:
        element_value = None
        return_strings = []
        is_return = False
        for j, doc_string in enumerate(doc_lines):
            if doc_string.strip() == 'Returns':
                is_return = True
            if doc_string.strip() in uniq_sections - {'Parameters', 'Returns'}:
                break
            if is_return:
                return_strings.append(doc_string)
        if return_strings:
            element_value = '\n'.join(return_strings[2:])
        if element_value:
            doc_data.append([metod, element_type, element_value]) 

    # Парсим атрибуты:
    element_type = 'attributes_dict'
    if row['doc'] is not None:
        element_value = None
        attr_strings = []
        is_attr = False
        for j, doc_string in enumerate(doc_lines):
            if doc_string.strip() == 'Attributes':
                is_attr = True
            if is_attr and (doc_string.strip() in uniq_sections - {'Attributes'}):
                break
            if is_attr:
                attr_strings.append(doc_string)
            if doc_string.strip() in ['', 'None']:
                attr_strings = []
                is_attr = False

        if attr_strings:
            attr_doc = ''
            for attr_string in attr_strings[2:]:
                if not attr_string.startswith(' ') and ':' in attr_string:
                    if attr_doc != '':
                        element_type = 'attributes_dict'
                        element_value = {element_value_key: attr_doc}
                        doc_data.append([metod, element_type, element_value])
                    continue
                element_value_key = attr_string.split(':')[0].strip()
                element_value_item = attr_string.split(':')[1].strip()
                element_value = {element_value_key: element_value_item}
                attr_doc = f"{attr_doc}\n{attr_string}"

            element_type = 'attributes_dict'
            element_value = {element_value_key: attr_doc}
            doc_data.append([metod, element_type, element_value])

    element_type = 'methods_dict'
    if row['doc'] is not None:
        element_value = None
        methods_strings = []
        is_method = False
        for j, doc_string in enumerate(doc_lines):
            if doc_string.strip() == 'Methods':
                is_method = True
            if is_method and doc_string.strip() in uniq_sections - {'Methods'}:
                break
            if is_method:
                methods_strings.append(doc_string)
            if doc_string.strip() in ['', 'None']:
                methods_strings = []
                is_method = False

        if methods_strings:
            method_doc = ''
            for method_string in methods_strings[2:]:
                if not method_string.startswith(' ') and ':' in method_string:
                    if method_doc != '':
                        element_type = 'methods_dict'
                        element_value = {element_value_key: method_doc}
                        doc_data.append([metod, element_type, element_value])
                    continue
                method_doc = f"{method_doc}\n{method_string}"

            element_type = 'methods_dict'
            element_value = {element_value_key: method_doc}
            doc_data.append([metod, element_type, element_value])      

    element_type = 'notes'
    if row['doc'] is not None:
        element_value = None
        notes_strings = []
        is_notes = False
        for j, doc_string in enumerate(doc_lines):
            if doc_string.strip() == 'Notes':
                is_notes = True
            if is_notes and doc_string.strip() in uniq_sections - {'Notes'}:
                break
            if is_notes:
                notes_strings.append(doc_string)
        element_value = '\n'.join(notes_strings[2:])
        if element_value:
            doc_data.append([metod, element_type, element_value])  

    element_type = 'examples'
    if row['doc'] is not None:
        element_value = None
        examples_strings = []
        is_examples = False
        for j, doc_string in enumerate(doc_lines):
            if doc_string.strip() == 'Examples':
                is_examples = True
            if is_examples and doc_string.strip() in uniq_sections - {'Examples'}:
                break
            if is_examples:
                examples_strings.append(doc_string)
        element_value = '\n'.join(examples_strings[2:])
        if element_value:
            doc_data.append([metod, element_type, element_value])  

    element_type = 'yields'
    if row['doc'] is not None:
        element_value = None
        yields_strings = []
        is_yields = False
        for j, doc_string in enumerate(doc_lines):
            if doc_string.strip() == 'Yields':
                is_yields = True
            if is_yields and doc_string.strip() in uniq_sections - {'Yields'}:
                break
            if is_yields:
                yields_strings.append(doc_string)
        element_value = '\n'.join(yields_strings[2:])
        if element_value:
            doc_data.append([metod, element_type, element_value])  

    element_type = 'raises'
    if row['doc'] is not None:
        element_value = None
        raises_strings = []
        is_raises = False
        for j, doc_string in enumerate(doc_lines):
            if doc_string.strip() == 'Raises':
                is_raises = True
            if is_raises and doc_string.strip() in uniq_sections - {'Raises'}:
                break
            if is_raises:
                raises_strings.append(doc_string)
        element_value = '\n'.join(raises_strings[2:])
        if element_value:
            doc_data.append([metod, element_type, element_value])  

    element_type = 'see_also'
    if row['doc'] is not None:
        element_value = None
        see_also_strings = []
        is_see_also = False
        for j, doc_string in enumerate(doc_lines):
            if doc_string.strip() == 'See Also':
                is_see_also = True
            if is_see_also and doc_string.strip() in uniq_sections - {'See Also'}:
                break
            if is_see_also:
                see_also_strings.append(doc_string)
        element_value = '\n'.join(see_also_strings[2:])
        if element_value:
            doc_data.append([metod, element_type, element_value])  


columns = ['object', 'element_type', 'element_value']
pandas_doc_df = pd.DataFrame(columns=columns, data = doc_data)

Посмотрим статистику по полученному датафрейму: 22589 записей!

pandas_doc_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 22589 entries, 0 to 22588
Data columns (total 3 columns):
 #   Column         Non-Null Count  Dtype 
---  ------         --------------  ----- 
 0   object         22589 non-null  object
 1   element_type   22589 non-null  object
 2   element_value  22589 non-null  object
dtypes: object(3)
memory usage: 529.6+ KB

Подсчитаем статистику по количеству записей разных типов:

pandas_doc_df.groupby(['element_type']).agg({'object': 'count'}).reset_index().sort_values('object',ascending=False, ignore_index=True)
Много всего, но только 3 йелда...
Много всего, но только 3 йелда...

Полученный датафрейм с документацией можно скачать: pandas_doc_df.csv и со статистикой: stat_doc.csv

Итоговый размер файла, который вместил в себя максимум документации pandas: 6,2 Мб

Примеры выборок из полученного датафрейма:

Выборки можно делать обычными средствами pandas

Ищем все данные по методу pandas.read_pickle:

# Ищем все данные по методу pandas.read_pickle:
pandas_doc_df[pandas_doc_df.object == 'pandas.read_pickle']
Вот так мы упаковали документацию упаковщика в банку с солеными огурцами...
Вот так мы упаковали документацию упаковщика в банку с солеными огурцами...
# Найти определенные типы описания по известному методу, напр., 'pandas.read_pickle'
pandas_doc_df[(pandas_doc_df.object == 'pandas.read_pickle') 
              & (pandas_doc_df.element_type == 'parameter_doc_dict')]
# Ищем все методы со строкой документации:
pandas_doc_df[pandas_doc_df.element_type == 'doc']
# Ищем все методы со словом excel/Excel:
all_names = pandas_doc_df[pandas_doc_df.element_type == 'name']
all_names[all_names.element_value.str.contains('excel') 
        | all_names.element_value.str.contains('Excel')]
# Ищем все методы с yields:
pandas_doc_df[pandas_doc_df.element_type == 'yields']

В последнем это: pandas.HDFStore.walk, pandas.DataFrame.items, pandas.DataFrame.iterrows

Наконец, найдем методы, в которых больше всего параметров:

# Найдем методы с максимальным количеством параметров:
sort_param = pandas_doc_df[pandas_doc_df.element_type == 'parameter_doc_dict']
sort_param = sort_param.groupby('object').agg({'element_value': 'count'}).reset_index().sort_values('element_value',ascending=False)
sort_param
Кто сказал, что в функции должно быть не больше 3 аргументов? "А-ха-ха", - донеслось из бамбуковых зарослей...
Кто сказал, что в функции должно быть не больше 3 аргументов? "А-ха-ха", - донеслось из бамбуковых зарослей...

Итого

Код можно было бы еще доработать, разобрав некоторые составляющие документации на списки или словари. Часть кода можно сократить (DRY), введя функции (но это будут весьма сложные функции... и лучшее - враг того хорошего, которое можно сделать в сэкономленное на отказе от лучшего время). Итоговый код можно скачать здесь: parse_pandas.ipynb

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Что бы вы хотели еще упаковать (возможен выбор нескольких вариантов)?
35.71% Ничего! От упаковки документации в датафрейм нет никакой пользы!5
21.43% Документацию numpy и scipy3
28.57% Документацию ML-библиотек4
14.29% А как насчет упаковки документации для SQL в БД SQL?2
21.43% Упакуйте уже что-нибудь ценное и материальное, положите под ёлку, а я утром проснусь и найду!3
14.29% Выбрал бы предпоследний пункт, но уже убрал ёлку… (((2
Проголосовали 14 пользователей. Воздержались 4 пользователя.
Теги:
Хабы:
Всего голосов 5: ↑5 и ↓0+5
Комментарии0

Публикации

Истории

Работа

Data Scientist
82 вакансии
Python разработчик
135 вакансий

Ближайшие события