Skip to content

Two way serialization - #1784

Closed
workaholicAB wants to merge 48 commits into
plotly:devfrom
workaholicAB:two-way-serialization
Closed

workaholicAB wants to merge 48 commits into
plotly:devfrom
workaholicAB:two-way-serialization

Conversation

@workaholicAB

@workaholicAB workaholicAB commented Sep 28, 2021 •

Copy link
Copy Markdown
Contributor

Idea

Provide non-lossy data exchange between server & renderer client in initial layout and callbacks, namely two-way serialization that allows either party sends any object then receive it back in its original type.

CHANGELOG

Added a serialization support of pandas.DataFrame as the very first type, which will be followed by a few more types like numpy.array, DateTime, Plotly figures and more.

Quick preview of how serialization work

- Layout

Before :

xValues = numpy.linspace(0, 3, 4)
yValues = numpy.linspace(4, 7, 4)
df = pandas.DataFrame({'x': xValues, 'y': yValues})
table = DataTable(data=df.to_dict('records'), id='data_table_1')

After :

xValues = numpy.linspace(0, 3, 4)
yValues = numpy.linspace(4, 7, 4)
df = pandas.DataFrame({'x': xValues, 'y': yValues})
table = DataTable(data=df, id='data_table_1')

- Callback

Before :

@callback(Output('data_table_2', 'data'), Input('data_table_1', 'data'))
def update(data): # <-- You would receive array object
	return data

After :

@callback(Output('data_table_2', 'data'), Input('data_table_1', 'data'))
def update(data): # <-- You would receive pandas.DataFrame object as it's what you've passed to data_table_1
	return data

CODE CHANGES

BREAKDOWNS OF TASKS

As described & outlined fully by @chriddyp

  • Milestone 1 - Strip out the object serialization stuff on either end of the request
  • Milestone 2 - Book-keeping: Pass {__type: 'JSON', __value: value} back
  • Milestone 3 - Python serializer
  • Milestone 4 - Integrate serializer into the dash library
  • Milestone 5 - Flesh out the serializers

TESTINGS

Needs sophisticated tests to verify it's working with many various edge cases

  • Sending DataFrame from server to client (layout)
  • Sending DataFrame from server to client (callbacks)
  • Sending DataFrame from client to server (callbacks)

Comment thread dash/dash-renderer/src/serializers/utils.js Outdated

@akksi akksi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks and works well overall as far as I can tell, but seems like it doesn't convert callback inputs back to the original datatype, e.g. callbacks receive data as JSON in {"__type": "DataFrame", "__value": "xyz"} format instead of actual Pandas DataFrame objects.

Comment thread dash/dash.py Outdated
Comment thread dash/dash-renderer/src/serializers/utils.js Outdated
Comment thread dash/dash-renderer/src/serializers/utils.js Outdated
@anders-kiaer

anders-kiaer commented Sep 30, 2021 •

Copy link
Copy Markdown
Contributor

@workaholicpanda @akksi @chriddyp Sorry for bumping into this PR, but this looks interesting! 🙂 If I understand this PR correctly, you want to try to support serialization/deserialization of custom Python objects before/after data is sent/received on the server. If so this is great, and something we have seen the need of as well. 🎉

Looking at the draft PR, it looks like the current sketch is to centralize all serialization in a class DashSerializer, using branching with isinstance to check for different types. I'm wondering if we risk the branch list could potentially grow quite a bit over time, with many isinstance branches/checks necessary on input/output (de)serialization, and still be inflexible for Dash components to e.g. let them define the optimal serialization in their case without them having to do some risky monkey patching of DashSerializer centrally? Would love to get your opinions on #1786 if that is relevant here, if not sorry for the noise.

@chriddyp

Copy link
Copy Markdown
Member

@anders-kiaer - Thanks for talking a look!

Yes the idea here is to support custom python objects but also to deserialize these objects in callbacks. So if you pass a dataframe into dcc.Store then you get a dataframe back from dcc.Store as Input or State.

You are correct that this is "global". The components end up receiving data in the same format as before (JSON), we're just handling more object to JSON and JSON back to object.

It would be great to have this on a per-component level. Also for it to be pluggable. We might be able to build some of that in to this PR while we're here.

Could you describe some use cases of both component and data types that you'd like to have custom serializers/deserializers for?

@anders-kiaer

anders-kiaer commented Sep 30, 2021 •

Copy link
Copy Markdown
Contributor

Could you describe some use cases of both component and data types that you'd like to have custom serializers/deserializers for?

The overall pattern would be components that require a complex data set, which would be easier to modify/adjust/interact with/give as input on the Python side through an appropriate Python object compared with a JSON structure which typically could be structured to make most sense/be most performant in the frontend (React) component.

Here is an attempt on finding a wide distribution of concrete examples:

  • I think quite a lot of the components in https://dash.plotly.com/dash-bio could get a simplified API if serialization/deserialization is supported on component level. In order to populate the components with data, the examples typically must fall back on either using "precompiled" JSON data structures or use read function utilities defined "outside" of the component. Having the serialization/deserialization closer to the component makes the usage of them easier, and reduce the risk of read utilities (converting domain specific Python object/data structure → JSON structure) or user made variations of the read utilities coming out of sync with the JSON structure expected by the component. Allowing higher level Python structures being given as input and serialization/deserialization within the component class also makes it easy for the components to internally change JSON structure they consume without it being a breaking change towards the component user.
  • A Dash component representing a calculator interface where the user can build mathematical expressions in the frontend.
    • Expression validation happens in frontend (for instant validation and user feedback).
    • You don't want the component user to parse the expression in the backend as well (since the frontend already does it). Encapsulating the serialization/deserialization logic within the component class would make it easier to provide it as a directly consumable high level Python object which could be e.g. something from sympy in this use case.
  • The input data to the custom Dash component here (choose PriorPosteriorDistribution from the top left dropdown) is originally a pd.DataFrame, but it includes a lot of redundant data:
    • Each of e.g. 1000+ model realizations go through some iterative update process, where model parameters are updated (gradually from prior to posterior).
    • In the original pd.DataFrame there are four columns: Realization model label, update step label, parameter name, parameter value.
    • Quite a bit of the data is redundant since the number of rows in the dataframe is the product of all of the unique entries, (#number of realizations)*(#number of update steps)*(#number of parameters). Utilizing the knowledge about the data structure of the input when converting the pd.DataFrame reduces the data sent quite a bit, and is also at the same time received in the frontend in a format more suitable for direct visualization with e.g. d3 frontend.
    • The take away here is that pd.DataFrame is a convenient data container for a lot of use cases, but how it is best serialized (and which assumptions the serialization method can take) is case dependent.
  • On the same demo page there are some map component prototypes where the data originally comes from an instance of a Python class defined in a standalone domain specific Python library (with Python C-code-extensions to make it performant). The classes could (through its C-code) efficiently convert detailed map data to associated .png-pictures + map coordinate bounds to the frontend component. To increase data image depth there are some tricks involved (e.g. utilize RGB channels not as colors but modulo 256 to increase data depth). All these serialization/deserialization steps would be nice to abstract away within the component itself (i.e. it could take in an instance of the map class and serialize to base64 .png + map bounds, and deserialize back to instance of the map class if requested).

These are the ones I can think of at the moment, but I'm quite sure the community will discover more of them 🙂

Yes the idea here is to support custom python objects but also to deserialize these objects in callbacks. So if you pass a dataframe into dcc.Store then you get a dataframe back from dcc.Store as Input or State.

Multiple valid input formats with serialization/deserialization on component level could perhaps take inspiration from this PR with the __type idea (if the below would work it would reduce the code changes necessary in the JavaScript world perhaps). Pseudo-code:

 from dash.development.base_component import Component

 class Store(Component):    
     @classmethod
     def serialize(prop_name, value: typing.Union[pd.DataFrame, JSONStructure]):
         if isinstance(pd.DataFrame):
              return {"__type": "DataFrame", "data": df.to_json()}  # The frontend component itself now needs to handle the extra layer/key `__type`, but since this function is part of the component itself it should be easy to keep in sync
         return {"__type": "JSONStructure, "data": to_json(value}  # Not a df - serialize as today

     @classmethod
     def deserialize(prop_name, value) -> typing.Union[pd.DataFrame, JSONStructure]:
         if value["__type"] == "DataFrame":
             return pd.read_json(value["data"])
         return from_json(value["data"])

Besides the flexibility component level serialization/deserialization gives (together with easier to understand error messages wrt. invalid input data since it could be given from Python when the data is closer to the user input format), I think one of the biggest benefit long term having it on component level would probably be the type annotations (which could give the Dash framework automatic documentation building of props on components and their valid Python input formats not relying entirely on docstrings + live editor feedback to the dash app developer through standard Python static type check tools).

@emmeb

emmeb commented Oct 1, 2021

Copy link
Copy Markdown

^^ Similar to what Anders says, component level serialization would simplify and make a lot of components in dash-bio faster. It would also reduce user error/frustration as files from different bio-software don't always come in the same format. Just under half of the dash-bio apps rely on JSON formats (Molecule3D, Needleplot, OncoPrint, Circos, Ideogram, Pileup), with the rest being csvs, Fasta, or PDB. Fasta and PDB files are the only files with a set structure, but they are large files, and serialization/deserialization to cache relevant parts would make the interactivity faster. All other files need to be structured perfectly to return the desired visualization, so component-level serialization/deserialization would help a lot.

@workaholicAB

workaholicAB commented Oct 7, 2021 •

Copy link
Copy Markdown
Contributor Author

@anders-kiaer @emmeb Thanks for sharing your thoughts on the PR.

I like the idea that throw the serialization job out to the Dash user because they should know much better what they're dealing with, what they want to send to client/server and vice versa.

The main motive of this PR, of course requested by @chriddyp, probably came because of potential data-loss & type-loss.

Overall, I think we can merge two ideas like below.

  • We can definitely give Dash users chances to customize Serialization by themselves. I think this and this would be enough.
    If you guys don't provide customized serialization, we will go through our own list.
  • We will serialize data, not any Component. In most cases, it would be a value of any Component's prop though.
    Thus, instead of making SomeCustomComponent, simply create your custom data type (for example SomeType or MyDataFrame) which has serialize and deserialize function defined.

This is solely my thoughts that needs to be validated and confirmed by @chriddyp for sure.

Thanks for reading!

…sh; branch 'dev' of github.com:plotly/dash into two-way-serialization
@anders-kiaer

anders-kiaer commented Oct 7, 2021 •

Copy link
Copy Markdown
Contributor

Thanks @workaholicpanda for expanding the serialization possibilities in Dash 🙂👏

We can definitely give Dash users chances to customize Serialization by themselves. I think this and this would be enough. [...] simply create your custom data type (for example SomeType or MyDataFrame) which has serialize and deserialize function defined.

If I understand those two commits correctly, the dash app developer would then do something like:

import pandas as pd
import dash_bio

df = pd.read_csv("some.csv")
data = # use df input above, but in some way add new bound methods serialize and deserialize relevant for the component

dash_bio.SomeBioComponent(id="some-id", data=data)

I think as a Dash app developer I would find that less intuitive compared to the slightly different Component @classmethod approach in #1786 where the serialize/deserialize functions are abstracted away in the component itself:

import pandas as pd
import dash_bio

df = pd.read_csv("some.csv")
dash_bio.SomeBioComponent(id="some-id", data=df)

Other benefits of the Component @classmethod approach:

  1. serialize/deserialize logic is maintained together with the consumer of the serialized data (i.e the JavaScript component code).
  2. You as Dash component author can provide user friendly error messages and type hint annotations to the Dash app developer. Many component props would not take a serialized pd.DataFrame. Having the serialization on component level, the Dash app developer could get traceback/error message in Python. In the centralized approach, all df.DataFrame inputs would be serialized by Dash and the error would instead turn up later in the JavaScript world with the serialized data?
  3. Easier to later improve auto-generated documentation of Dash component prop input. In the Component class approach the input type(s) a particular prop allows are defined within the component. In the centralized DashSerializer implementation most (all?) of the coupling between serializable types and which component props accept these type(s) do not exist.

@workaholicAB

Copy link
Copy Markdown
Contributor Author

@anders-kiaer Glad to have real users' feedback prior to release.

A few points resonate with you.

  • Even the same data type can be used in slightly different ways per use-case (aka Component in our context)
  • As a Dash user, it might be more obvious and handy to deal with serialization per Component.

However, I wanted to remind the original purpose that we wanted to allow complex data type to be transmitted via network requests (between Dash serving daemon and React client side) for initial layout & callbacks.

Having that said, I feel #1786 could be a separate milestone than this one, and once we have this done, it might be much easier and quicker to implement #1786 for sure.

After all, definitely something to be determined by code owners, so I'd like @chriddyp @alexcjohnson @jackparmer to make decision on this requirement. 🙏

@jackparmer

jackparmer commented Oct 9, 2021 •

Copy link
Copy Markdown
Contributor

Chatting with @chriddyp , we're going to keep the centralized direction that @workaholicpanda has started here. This won't prevent us from adding serializations for Dash Bio and other component suites, and could be a stepping stone to component-level serializations in the future. Right now, our main priority is to enable dataframe serialization to eliminate DataTable ceremony like this:

import dash_table as dt
...
dt.DataTable(
        id='tbl', data=df.to_dict('records'),
        columns=[{"name": i, "id": i} for i in df.columns],   
)

With this PR, the above will be simplified to

dt.DataTable(df,  id='tbl')

@workaholicAB

workaholicAB commented Oct 20, 2021 •

Copy link
Copy Markdown
Contributor Author

Remaining things

  • Complete fastparquet engine
  • Complete pyarrow engine
  • Add new tests

Blocking issues -

@akksi akksi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Serialization doesn't respect the order of columns yet, e.g.
app.layout = html.Div(DataTable(DataFrame({'b': [2], 'a': [1]})))

Not really technical, but I think we can now use DataTable(df, instead of DataTable(data=df, in the examples.

Comment thread dash/dash-renderer/src/serializers/index.js Outdated
Comment thread dash/dash-renderer/src/serializers/index.js Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants