Shiny vs. Dash: A Side-by-side comparison
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
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)
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')
.
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.
R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.