Shiny vs. Dash: A Side-by-side comparison

Intro

Shiny is by leaps and bounds the most popular web application framework for R. It provides the convenient ability to write fully dynamic web applications using only R code. Dash is a fairly new Python web application framework with the same approach. Although Dash is often thought of as Python's Shiny, there are some important differences the should be highlighted before you run off and re-write all your Shiny apps with Dash.

In this post I'm going to start by comparing some Shiny code to Dash code for an equivalent app. I'll then move on to talking about a couple of the unseen differences between the two: the ability to share data across callbacks, and ease of deployment.

Setup

We'll start with a little setup. We'll use the mtcars data from R and use linear regression to predict a car's miles per gallon from a number of cylindars (cyl), displacement (disp), quarter mile time (qsec), and if the car is manual or automatic (am). I chose these because it gives us a nice preview of the different types of selectors on the UI side: sliders, radio buttons, and boolean value selection.

Feel free to take a look at the setup code for R and Python, below.

R Setup Code
# load our data 
data(mtcars)

# make cyl a factor
mtcars$cyl <- as.factor(mtcars$cyl)

# run our regression
fit <- lm(mpg ~ cyl + disp + qsec + am, data = mtcars)

preds <- function(fit, disp, qsec, cyl, am){
    # get the predicted MPG from new data
    mpg <- predict(object=fit, 
                   newdata = data.frame(
                       cyl=factor(cyl, levels=c('4', '6', '8')), 
                       disp=disp, 
                       qsec=qsec, 
                       am=am))

    # return as character string that can be easily rendered
    return(as.character(round(mpg, 2)))
}
Python Setup Code
import pandas as pd
import numpy as np

from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import OneHotEncoder

# load our data
mtcars = pd.read_csv('mtcars.csv',
                    dtype={'cyl': str,
                          'am': np.float64})

# create and fit a one-hot encoder--we'll want to reuse this in the app as well
cyl_enc = OneHotEncoder(categories = 'auto', sparse=False)
cyl_enc.fit(mtcars['cyl'].values.reshape(-1,1))

y = mtcars['mpg']
# we need to concatenate the one-hot (dummy) encoded values with
# the values from mtcars
X = np.concatenate(
    (mtcars[['disp', 'qsec', 'am']].values, 
     cyl_enc.transform(mtcars['cyl'].values.reshape(-1,1))),
     axis=1)

# fit our regression model
fit = LinearRegression()
fit.fit(X=X, y=y)

def preds(fit, cyl_enc, disp, qsec, am, cyl):
    # construct our matrix
    X = np.concatenate(
        (np.array([[disp, qsec, am]]),
         cyl_enc.transform([[cyl]])),
         axis=1)
    # find predicted value
    pred = fit.predict(X)[0]
    # return a rounded string for nice UI display
    return str(round(pred, 2))

A Shiny App

First let's dive into the Shiny app. For those familiar with Shiny, this will be very straight forward--it reads like many of the examples in shiny man pages and tutorials.

app <- shinyApp(ui = fluidPage(title = 'Predicting MPG',
                # create inputs for each variable in the model 
                    sliderInput('disp', label = 'Displacement (in cubic inches)', 
                                min = floor(min(mtcars$disp)), 
                                max = ceiling(max(mtcars$disp)),
                                value = floor(mean(mtcars$disp))),


                    sliderInput('qsec', label='Quarter mile time',
                               min = floor(min(mtcars$qsec)), 
                                max = ceiling(max(mtcars$qsec)),
                                value = floor(mean(mtcars$qsec))),

                    # this will return a character vector of length 1
                    # that will get converted into a factor
                    radioButtons('cyl', label='Number of cylinders',
                                choices = levels(mtcars$cyl),
                                inline=TRUE),

                    # am is binary, 1/0, so we can coerse logical to integer   
                    checkboxInput('am', label='Has manual transmission'),

                    # return our estimate
                    h3("Predicted MPG: ", textOutput('prediction'))),


               server = function(input, output){
                   # pass our inputs to our prediction function defined earlier
                   # and pass that result to the output
                   output$prediction <- renderText({
                        preds(fit= fit, 
                            disp = input$disp,
                            qsec = input$qsec,
                            cyl = input$cyl,
                            am = as.integer(input$am))
                   })
               })

# and run it
runApp(app)
Screenshot%2Bfrom%2B2019-03-06%2B17-30-53.jpg

I think something that really stands out well here is the simplicity--this app comes in at just 35 lines of code--and that includes comments! Inputs and outputs are well defined and the flow of the app is easy to understand.

At no point have we had to mess with css, div tags, or really think about the UI. Despite that, we get a UI that looks really nice.

I am especially happy with how easy it is to get good looking sliders with almost no configuration--something that isn't so simple in Dash.

A Dash App

For those unfamiliar with Dash, it has a similar conceptual layout as Shiny: The app is broken up into a section for the UI and a section for server side processing. We also have a concept of inputs and outputs, and like shiny, outputs can be fed into other server side functions for further processing.

The UI

The Dash UI is created by using various javascript components, built on top of reactjs tied together with HTML components. Let's take a look at the code.

It's pretty straight forward. We can use any valid HTML tags as well as a ton of javascript input and output components. Plus, the D3-based plotly package is very well integrated.

In this app I uss the slider from Dash-DAQ, which provides some higher-level or enhanced controls not included in the Dash core components. I played around with the slider from core componenets for awhile, but was never able to get it to look half as nice as the one from Shiny.

Another difference of note between Dash and Shiny--Dash comes with also no assumptions about how you will style your app. This means the sky is the limit when it comes to customizing the look of the app, and the ability to customise is front and center. It also means some of the operations that are simple in Shiny become more convoluded in Dash.

Example: To get Shiny radio buttons to render inline, we pass the argument inline=TRUE. In Dash, we'll need to use the labelStyle argument of dcc.RadioItems. We pass the dict {'display': 'inline-block'}, which will then be passed to the component itself. This appraoch allows for more flexibility, but as always, comes with a cost.

Dash Server Side

# load the resuired modules
import dash
import dash_core_components as dcc
import dash_html_components as html
import dash_daq as daq


# create an instance of a dash app
app = dash.Dash(__name__)
app.title = 'Predicting MPG'

# dash apps are unstyled by default
# this css I'm using was created by the author of Dash
# and is the most commonly used style sheet
app.css.append_css({
    "external_url": "https://codepen.io/chriddyp/pen/bWLwgP.css"
})

# I compute these up front to avoid having to
# calculate thes twice
unq_cyl = mtcars['cyl'].unique()
unq_cyl.sort() # so it's in a nice order
opts_cyl = [{'label': i, 'value': i} for i in unq_cyl]


app.layout = html.Div([

        html.H5('Displacement (in cubic inches):'),
        html.Br(), html.Br(),
        daq.Slider(
            id='input-disp',
            min=np.floor(mtcars['disp'].min()),
            max=np.ceil(mtcars['disp'].max()),
            step=.5,
            dots=False,
            handleLabel={"showCurrentValue": True,"label": "Value"},
            value=np.floor(mtcars['disp'].mean())),

        html.H5('Quarter mile time:'), 
        html.Br(),
        daq.Slider(
            id='input-qsec',
            min=np.floor(mtcars['qsec'].min()),
            max=np.ceil(mtcars['qsec'].max()),
            dots=False,
            handleLabel={"showCurrentValue": True,"label": "Value"},
            step=.25,
            value=np.floor(mtcars['disp'].mean())),

        html.H5('Number of cylinders:'),
        dcc.RadioItems(
            id='input-cyl',
            options=opts_cyl,
            value=opts_cyl[0].get('value'),
            labelStyle={'display': 'inline-block'}),

        daq.ToggleSwitch(
            id='input-am',
            label='Has manual transmission',
            value=False),

        html.H2(id='output-prediction')
])
# callback will watch for changes in inputs and re-execute when any
# changes are detected. 
@app.callback(
    dash.dependencies.Output('output-prediction', 'children'),
    [
        dash.dependencies.Input('input-disp', 'value'),
        dash.dependencies.Input('input-qsec', 'value'),
        dash.dependencies.Input('input-cyl', 'value'),
        dash.dependencies.Input('input-am', 'value')])
def callback_pred(disp, qsec, cyl, am):
    # pass values from the function on to our prediction function
    # defined in setup
    pred = preds(fit=fit, 
                 cyl_enc=cyl_enc, 
                 disp=disp, 
                 qsec=qsec, 
                 am=np.float64(am), 
                 cyl=cyl)
    # return a string that will be rendered in the UI
    return "Predicted MPG: {}".format(pred)

# for running the app
if name == 'main':
    app.run_server(debug=True)

Server-side processing is accomplished by decorating standard python functions with the callback decorator. This decorator takes the inputs and outputs as arguments, and will trigger when the inputs and outputs change. This just like the reactive model in Shiny.

Notice how dash uses the HTML tag id to reference objects. In the callback decorator, we assign the output to an id of output-prediction and then in the UI side (the app layout), we display that value with html.H2(id='output-prediction').

Screenshot from 2019-03-06 18-00-33.png

I always end up messing aroud a lot more with the UI of Dash apps than I do with shiny apps--getting the all the design elements to line up the way I want them to often ends up being a chore. Maybe it's time to actually learn about css...

Finally, Here's what the Dash app UI looks like. I like the sliders, but they don't provide as much information as the Shiny ones.

Feature Comparison

Let's talk about some of the hidden features and quirks of Shiny and Dash. I'll be focusing on features that are critical for production application development. The difficulty in getting the UI just right could weight less in your framework choice if you need to be able to deploy your app on google app engine standard environment, for example.

Deployment

When it comes time to deploy your Dash app, the Google App Engine standard environement is your friend.

Intended to run for free or at very low cost, where you pay only for what you need and when you need it. For example, your application can scale to 0 instances when there is no traffic.

But the standard environment only supports a handful of languages--Python is one of them, R is not. To deploy a Shiny app, you'll need to use the Flexible environment, which means you need to pay for all your app's uptime rather than just when it has users. I've built apps for clients in Dash instead of Shiny becasue they didn't have a budget for deployment. The project manager can pay their GCP bill out of pocket becasue it usually ends up being less than $1/month. A flexible environment could have been closer to $20/month.

Shiny, of course, has shinyapps.io. Their free tier is awesome for tinkerers, but less so for a client that doesn't want RStudio branding on their app. Their non-rstudio branded option was $9/month--again outside the clients budget. That being said--deployment to shinyapps.io is the easiest remote deployment I've ever done.

Sharing data across callbacks

This is where Shiny is miles ahead of Dash. Objects in memory of a Dash session are not owned by given user's session. Becasue of that, it is bad practice to alter global objects in the scope of a callback. A side effect of this is you can't have a callback return an object that gets further proccessed by other callbacks, and then finally returned to the user later.

There are work arounds for this. They are well documented here. Some of the work arounds will perform poorly if the data to pass are large and all of them must deal with the overhead of seralization.

One one hand, this certainly makes building more complex apps more difficult. Many of the apps I've built with Shiny are wizard-style apps, where the user is guided through a multi stage procces of subsequent data processsing steps. This would be much more difficult in Dash. But on the other hand, this forces the programmer to simplify their code and be deliberate about data that will be passed around. Perhaps it's not such a bad limitation to have.

Conclusion

Shiny is a sleek, feature rich framework. It lowers the barrier to entry for creating rich interactive web apps but is also hackable, for those who want to build something complex and customized and who have the will to hammer though. The Shiny community is awesome.

Dash is pretty new and still a little rough around the edges. It was built to be customized, so those who love hacking and tweaking may find a friend in Dash. And since it is built on Python and Flask, the ecosystem available for use in Dash apps is already huge.

You can't go wrong with either, but for now I default to Shiny if the app is going to get complex and use Dash if I'm hoping to deploy a simple app for cheap.