Matplotlib и pickle

Грубо говоря, есть интерактивное Python приложение для многочасового подбора всякой разности с использованием Matplotlib, OpenCV, SciPy, Numpy в среде JupyterLab (IPython записка .ipynb). Хотелось бы к нему добавить новый функционал, что-то, типа, сохранения/восстановления на контрольных точках, а может и в любой момент.

В принципе, в стандартной библиотеке Python есть модуль pickle с похожей функциональностью. Похоже, он более менее совместим со всеми компонентами, кроме Matplotlib, у которого, в основном, интерактивные "кнопки" не желают мариноваться (сериализоваться).

К сожалению, ни я, в документации Maptplotlib, ни Яндекс, по запросу "pickle site:matplotlib.org", не нашёл связанного изложения и/или рецепта. Есть только несколько десятков, вопросов и ответов с противоречивыми вердиктами.

В документации pickle описаны способ обхода такого рода проблем: Handling Stateful Objects, но применить его можно различными способами.

Вариант "прямого" кодирования в целевых классах

После "творческого теоретического домысливания" Сохранение состояния pickle и классы наследники выходит примерно таким:

class MySuper:
    def __getstate__(self):
        # Ввиду разного порядка добавления элементов,
        # размерчики могут становится немного странными.
        self.__figwidth = self.fig.get_figwidth()
        self.__figheight = self.fig.get_figheight()
        self.__scbt_status = self.scbt.get_status()
        state = self.__dict__.copy()
        # pickle не любит кнопок и т.п.
        del state['scbt']
        return state

    def __setstate__(self, state):
        self.__dict__.update(state)
        self.fig.set_figwidth(self.__figwidth)
        self.fig.set_figheight(self.__figheight)
        self.__init_interface(scbt_actives=self.__scbt_status)

    def __init_interface(self, scbt_actives=None):
        self.ax['super'].clear()  # Внезапная опытная заплатка
        self.scbt = mpl.widgets.CheckButtons(self.ax['super'],
                                             ["Super", "Hyper"],
                                             actives=scbt_actives)
        self.scbtcid = self.scbt.on_clicked(self.scbt_on_clicked)


    def __init__(self):
        self.fig, self.ax = plt.subplot_mosaic([
                                ['plt', 'super'],
                                ['plt', 'derive'],
                                ['plt', '.']],
                                width_ratios=[80, 20],
                                height_ratios=[15, 15, 70],
                            )
        self.ax['plt'].plot(np.arange(-1, 10)**2)
        self.__init_interface()
        self.scbt_cnt = 0

    def scbt_on_clicked(self, label):
        print(f"MySuper.scbt_on_clicked({label, self.scbt_cnt=})")
        self.scbt_cnt += 1


class MyIntermediate(MySuper):
    def __init__(self):
        super().__init__()
        self._has_intermediate = True

  
class MyDerive1(MyIntermediate):
    def __getstate__(self):
        self.__dcbt1_status = self.dcbt1.get_status()
        state = super().__getstate__()
        del state['dcbt1']
        return state

    def __setstate__(self, state):
        super().__setstate__(state)
        self.__init_interface(dcbt1_actives=self.__dcbt1_status)

    def __init_interface(self, dcbt1_actives=None):
        self.ax['derive'].clear()  # Внезапная опытная заплатка
        self.dcbt1 = mpl.widgets.CheckButtons(self.ax['derive'],
                                              ["Derive1", "Child1"],
                                              actives=dcbt1_actives)
        self.dcbt1cid = self.dcbt1.on_clicked(self.dcbt1_on_clicked)
        assert self._has_intermediate

    def __init__(self):
        super().__init__()
        self.__init_interface()
        self.dcbt1_cnt = 0

    def dcbt1_on_clicked(self, label):
        print(f"MyDerive1.dcbt1_on_clicked({label, self.dcbt1_cnt=})")
        self.dcbt1_cnt += 1

Полностью, с примером использования, в записке: https://github.com/Serge3leo/temp-cola/blob/main/ruSO/1614688-pickle-и-классы-наследники.ipynb

Как бы, 7 упоминаний названия для каждой кнопки или 4 "self.fig", на любителя, зато все ограничения и грабли на поверхности, т.е. имеются все удобства для их обхода.

Правда, при обнаружении новых опытных граблей, что не исключено, придётся исправлять всюду.

Вариант обёрток проблемных классов Matplotlib

class PicklingFigure:
    """Обёртка, для сохранения размера рисунка. 
    
       Ввиду разного порядка добавления элементов, после 
       восстановления размерчики могут становится немного 
       странными. Использование:
       >>> fig, ax = plt.subplots()
       >>> fig_pickling = PicklingFigure(fig)
    """
    def __getstate__(self):
        self.__figwidth = self._fig.get_figwidth()
        self.__figheight = self._fig.get_figheight()
        return self.__dict__.copy()
        
    def __setstate__(self, state):
        self.__dict__.update(state)
        self._fig.set_figwidth(self.__figwidth)
        self._fig.set_figheight(self.__figheight)

    def __init__(self, fig):
        assert isinstance(fig, mpl.figure.Figure)
        self._fig = fig

class PicklingCheckButtons(mpl.widgets.CheckButtons):
    """Обёртка для использования совместно с `pickle`. 
    
       Всюду использовать `PicklingCheckButtons(ax, ...)`
       вместо `mpl.widgets.CheckButtons(ax, ...)`.
    """
    def __getstate__(self):
        for l in self.labels:
            if l.figure != self.ax.figure:
                print(f"WARNING: {type(self).__name__}.__getstate__: "
                      f"{l.figure, self.ax.figure=}")
        assert self.ax.figure is not None
        return {'_pickling_ax': self.ax,
                '_pickling_labels': self.labels,
                '_pickling_eventson': self.eventson,  # Вроде упоминается в документации
                '_pickling_status': self.get_status(),
                '_pickling_init_args': self._pickling_init_args,
                '_pickling_init_kwargs': self._pickling_init_kwargs,
                '_pickling_cids_funcs': self._pickling_cids_funcs,
                '_pickling_next_cid': self._pickling_next_cid,
                '_pickling_ax_figure': self.ax.figure,
               }
        
    def __setstate__(self, state):
        state['_pickling_ax'].clear()
        self.__init__(*state['_pickling_init_args'], 
                      **state['_pickling_init_kwargs'])
        self.ax = state['_pickling_ax']
        self.labels = state['_pickling_labels']
        self.eventson = state['_pickling_eventson']
        current = self.get_status()  # У 3.8 нет `set_active(i, state=s)`
        for i in range(len(state['_pickling_status'])):
            if current[i] != state['_pickling_status'][i]:
                self.set_active(i)
        self._pickling_next_cid = state['_pickling_next_cid']
        for cid, (_, func) in state['_pickling_cids_funcs'].items():
            self.on_clicked(func, _pickling_cid=cid)
        # Минимальный контроль потенциальных граблей
        assert state['_pickling_ax_figure'] == self.ax.figure, (
                    f"{state['_pickling_ax_figure'], self.ax.figure=}")
        assert self.ax.figure is not None
        # Внезапные опытные грабли, которых почему-то не было у альтернативы
        for l in self.labels:
            if l.figure != self.ax.figure:
                print(f"WARNING: {type(self).__name__}.__setstate__: "
                      f"{l.figure, self.ax.figure=}")
                l.figure = self.ax.figure
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._pickling_init_args = args
        self._pickling_init_kwargs = kwargs
        self._pickling_cids_funcs = {}
        self._pickling_next_cid = 0

    def on_clicked(self, func, _pickling_cid=None):
        scid = super().on_clicked(func)
        if _pickling_cid is None:
            _pickling_cid = self._pickling_next_cid
            self._pickling_next_cid += 1
        self._pickling_cids_funcs[_pickling_cid] = (scid, func)
        return _pickling_cid

    def disconnect(self, cid):
        scid, func = self._pickling_cids_funcs[cid]
        super().disconnect(scid)
        del self._pickling_cids_funcs[cid]

Полностью, с примером использования, в записке: https://github.com/Serge3leo/temp-cola/blob/main/ruSO/1614729-Matplotlib-и-pickle.ipynb

Немного объёмно, зато в использовании кратко. Однако, ограничения и грабли скрыты под капотом.

К примеру, CheckButtons не даёт документированного доступа к своим атрибутам и атрибутам своих частей, приходится запоминать аргументы в момент создания, что как-то не очень.

Альтернативы pickle

Мотивом рассмотрения pickle для сохранения состояния послужила сравнительная простота использования. К примеру, если сравнивать со стандартным модулем json, то у того сразу возникают проблемы:

  • namedtuple, вообще, полная беда;
  • типы Numpy;
  • пользовательские классы.

В целом, использование json требует немаленьких ручных доработок (реализация функций default() и object_hook() для многих, почти всех классов), как для классов целевого кода, так и для классов кода отображения. Правда, можно надеяться, результат окажется более надёжным и предсказуемым, ну, после отладки и тестирования.

Напротив, использование pickle, в моём случае, требует сравнительно небольших доработок только некоторых частей кода отображения.

Но, конечно, объём файла сохраненного состояния pickle в тысячи раз больше (десятки мегабайт pickle против десятков килобайт json). Кроме того уже встретились и могут ещё выскочить странные грабли. Потому и есть вопросы. Возможно, кто-то уже наступал?

P.S.

Пока не разобрался в способах "просмотра"/"анализа" файла pickle, в случае если его, по тем или иным причинам не получается загрузить.


Ответы (0 шт):