Trigger a dash dashboard on a button click

Question:

I am working on a dash app, where I try to integrate ExplainerDashboard.

If I do it like this:

app.config.external_stylesheets = [dbc.themes.BOOTSTRAP]

app.layout = html.Div([
    html.Button('Submit', id='submit', n_clicks=0),
    html.Div(id='container-button-basic', children='')
])

X_train, y_train, X_test, y_test = titanic_survive()
model = LogisticRegression().fit(X_train, y_train)
explainer = ClassifierExplainer(model, X_test, y_test)
db = ExplainerDashboard(explainer, shap_interaction=False)
db.explainer_layout.register_callbacks(app)     

@app.callback(
    Output('container-button-basic', 'children'),
    Input('submit', 'n_clicks'),
)
def update_output(n_clicks):

    if n_clicks == 1:
        return db.explainer_layout.layout()

The dashboard gets triggered on the button click, however, it is calculated before I click the button and when the dash starts. If I change it and put the calculations inside the callback like this, I get the dashboard but it looks the register callback doesn’t work and all the plots are empty

app.config.external_stylesheets = [dbc.themes.BOOTSTRAP]

app.layout = html.Div([
    html.Button('Submit', id='submit', n_clicks=0),
    html.Div(id='container-button-basic', children='')
])

X_train, y_train, X_test, y_test = titanic_survive()
model = LogisticRegression().fit(X_train, y_train)
explainer = ClassifierExplainer(model, X_test, y_test)

@app.callback(
    Output('container-button-basic', 'children'),
    Input('submit', 'n_clicks'),
)
def update_output(n_clicks):

    if n_clicks == 1:
        db = ExplainerDashboard(explainer, shap_interaction=False) 
        db.explainer_layout.register_callbacks(app)     
        return db.explainer_layout.layout()
Asked By: Tasos

||

Answers:

The reason why your example doesn’t work is that in Dash, callbacks must be registered before the server starts. Hence, you cannot register new callbacks from within a callback.

Data pre-processing pipeline

I think the cleanest solution would be move the data processing to a pre-processing pipeline. It could be something as simple as a notebook running on the Dataiku node. The code would be along the lines of

from explainerdashboard import ClassifierExplainer
from explainerdashboard.datasets import titanic_survive
from sklearn.linear_model import LogisticRegression

X_train, y_train, X_test, y_test = titanic_survive()
model = LogisticRegression().fit(X_train, y_train)
explainer = ClassifierExplainer(model, X_test, y_test)
explainer.dump("/data/dataiku/titanic.joblib") # save to some writeable location

The corresponding webapp code would be something like,

import dash_bootstrap_components as dbc
from dash import Dash
from explainerdashboard import ClassifierExplainer, ExplainerDashboard

explainer = ClassifierExplainer.from_file("/data/dataiku/titanic.joblib")  # load pre-processed data
db = ExplainerDashboard(explainer, shap_interaction=False)
app.config.external_stylesheets = [dbc.themes.BOOTSTRAP]
app.layout = db.explainer_layout.layout()
db.explainer_layout.register_callbacks(app)

The deployment process would then be to (1) run the notebook and (2) (re)start the webapp backend. Note that this process must be repeated for the app to pickup new data.

Callback registration using mock data

Another approach could be to use a mock dataset that is small, but has the same structure as your normal (large) dataset, for constructing the ExplainerDashboard during app initialisation. This approach enables fast initial loading, and callback registration before app start. You could then use a callback to load the complete dataset afterwards, i.e. similar to your original idea. Here is some example code,

import dash_bootstrap_components as dbc
from dash import html, Dash, Output, Input, dcc
from dash.exceptions import PreventUpdate
from explainerdashboard import ClassifierExplainer, ExplainerDashboard
from explainerdashboard.datasets import titanic_survive
from sklearn.linear_model import LogisticRegression


def get_explainer(X_train, y_train, X_test, y_test, limit=-1):
    model = LogisticRegression().fit(X_train[:limit], y_train[:limit])
    return ClassifierExplainer(model, X_test[:limit], y_test[:limit])


def inject_inplace(src, dst):
    for attr in dir(dst):
        try:
            setattr(dst, attr, getattr(src, attr))
        except AttributeError:
            pass
        except NotImplementedError:
            pass


X_train, y_train, X_test, y_test = titanic_survive()
# Create explainer with minimal data to ensure fast initial load.
explainer = get_explainer(X_train, y_train, X_test, y_test, limit=5)
dashboard = ExplainerDashboard(explainer, shap_interaction=False)
# Setup app with (hidden) dummy classifier layout.
dummy_layout = html.Div(dashboard.explainer_layout.layout(), style=dict(display="none"))
app = Dash()  # not needed in Dataiku
app.config.external_stylesheets = [dbc.themes.BOOTSTRAP]
app.layout = html.Div([
    html.Button('Submit', id='submit', n_clicks=0),
    dcc.Loading(html.Div(id='container', children=dummy_layout), fullscreen=True)
])
# Register the callback before the app starts.
dashboard.explainer_layout.register_callbacks(app)


@app.callback(Output('container', 'children'), Input('submit', 'n_clicks'))
def load_complete_dataset(n_clicks):
    if n_clicks != 1:
        raise PreventUpdate

    # Replace in-memory references to the full dataset to sure callbacks target the full dataset.
    full_explainer = get_explainer(X_train, y_train, X_test, y_test)
    inject_inplace(full_explainer, explainer)

    return ExplainerDashboard(explainer, shap_interaction=False).explainer_layout.layout()


if __name__ == "__main__":
    app.run_server(port=9024, debug=False)
Answered By: emher

TL;DR

My understanding per my experiments with Dash (a React app) is callbacks do get registered after Dash app’s initialisation (as you are doing inside a callback in your second code) but they don’t get passed to the web page as the React app on the web page is already loaded, so if you refresh page, the React app gets all callbacks (even the new ones registered after Dash app’s initialisation).

Long version

The problem with your second code wherein you are instantiating ExplainerDashboard in the callback is that the callbacks registered now by ExplainerDashboard is not passed on to the already-loaded web page.

The reason being Dash loads the callbacks on page load, so any callbacks added after the page is loaded do not work. I did various experiments after checking the code of ExplainerDashboard and reading Dash’s documentation. And fortunately, I came across a classic trick (from web dev.) that overrides the issue.

The trick is to initiate ExplainerDashboard on the first page load, show a in-progress message, then activate the button after it’s initiated, then load the initiated ExplainerDashboard on button click. Below is the tested and verified code for the same. Of course, it’s a POC, which can be improved to put into production.

from dash import Dash, dcc, html, Input, Output

from sklearn.linear_model import LogisticRegression
from explainerdashboard import ClassifierExplainer, ExplainerDashboard
from explainerdashboard.datasets import titanic_survive


app = Dash(__name__)

app.index_string = '''
<!DOCTYPE html>
<html>
    <head>
        {%metas%}
        <title>{%title%}</title>
        {%favicon%}
        {%css%}
        <script>
            window.onload = function() {
                setTimeout(function() {
                    if (document.getElementById("explainer_is_loaded")) {
                        console.log("Y");
                        document.getElementById("submit").textContent = "Submit";
                    } else {
                        console.log("N");
                        window.location = "/";  /* refresh the page */
                    }
                }, 5000);
            };
        </script>
    </head>
    <body>
        {%app_entry%}
        <footer>
            {%config%}
            {%scripts%}
            {%renderer%}
        </footer>
    </body>
</html>
'''

# app.config.external_stylesheets = [dbc.themes.BOOTSTRAP]

app.layout = html.Div([
    html.Button('Loading... Please wait...', id='submit', n_clicks=0),
    html.Div(id='container-button-basic', children=''),

    html.Button('', id='hidden-btn1', n_clicks=0, hidden=True),
    html.Div(id='hidden-div1', children='', hidden=True),
])

X_train, y_train, X_test, y_test = titanic_survive()
model = LogisticRegression().fit(X_train, y_train)
explainer = ClassifierExplainer(model, X_test, y_test)

db = None


@app.callback(
    Output('hidden-div1', 'children'),
    Input('hidden-btn1', 'n_clicks'),
)
def init_explainer(n_clicks):
    global db

    if not db:
        db = ExplainerDashboard(explainer, shap_interaction=False)
        db.explainer_layout.register_callbacks(app)
    else:
        return html.Div(id='explainer_is_loaded', hidden=True),


@app.callback(
    Output('container-button-basic', 'children'),
    Input('submit', 'n_clicks'),
)
def load_explainer(n_clicks):
    global db

    if n_clicks >= 1 and db:
        return db.explainer_layout.layout()


if __name__ == '__main__':
    app.run_server(debug=True)

Let me explain new parts of the program in detail:

  1. app.index_string

This part customizes Dash’s HTML Index Template to include our page refresh code. It refreshes the page every 5 seconds until it finds #explainer_is_loaded element.

  1. init_explainer(n_clicks)

This part adds a callback that runs on page load as I am not checking for n_checks in this callback. So, we initiates the ExplainerDashboard under this callback.

  1. load_explainer(n_clicks)

This part is your own callback modified to simply return db.explainer_layout.layout() when the button is clicked on the web page.

  1. global db

Since the ExplainerDashboard is instantiated and used in different functions (and on different page loads), we needed a global variable to store its object.

References

Answered By: IamAshKS
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.