DonaldRauscher.com

A Blog About D4T4 & M47H

How to Deploy a Shiny App on Google Kubernetes Engine

02 October ’17

Shiny is an awesome tool for building interactive apps powered by R. There are a couple options for deploying Shiny apps. You can deploy to Shinyapps.io. You can also deploy on your own machine using open source Shiny Server. This tutorial shows how to setup a Docker container for a Shiny app and deploy on Google Kubernetes Engine. And because deploying the "Hello World" example is entirely unsatisfying, I chose to build an app to help guide strategy for a game I recently played.

Farkle - A Game of Guts & Luck Probability

My wife and I recently stumbled upon this game in one of our relative's game closets. The game is very simple and is played with just 6 dice (how they get people to buy a game that is comprised of only 6 dice is beyond me). Different rolls are worth different amounts of points. 4 of a kind is worth 1000 points, 1-2-3-4-5-6 is worth 1500 points, three 6s is worth 600 points, etc. 1s and 5s not used in other combinations count as 100 and 50 points respectively. Detailed scoring rules here. For instance, a roll of 1-3-3-4-5-3 = 300 + 100 + 50 = 450 points.

Any dice that don't count towards your score can be rolled again. However, and this is the catch, if you score no points on a roll, you lose all points accumulated up to that point. So in the above example, we could choose to roll 1 die (from the non-scoring 4), but we will lose the 450 points that we've banked if we don't roll a 1 or a 5 (the only scoring options with a single die).

Here is a summary of the expected number of points and the probability of scoring zero points on a single roll:

Dice RemainingP(Points = 0)E[Points]
166.7%25.0
244.4%50.0
327.8%83.6
415.7%132.7
57.7%203.3
62.3%388.5

As expected, the more dice we roll, the more likely we are to not get zero points. Furthermore, since more high scoring options are available with each die, each incremental die gives us more expected points than the last.

If you manage to score using all 6 of the dice, you get to start rolling again with all 6 dice. A short-sighted player may observe that and thus not be willing to risk rolling that last die. However, those 500 and 550 scenarios are too low! The average 6 dice roll results in 0 points just 2.3% of the time and an average of points if not zero. Incorporating this into our expectation, the average number of points on the next two rolls is actually . And this is still conservative. You have the option to roll a third time after that second roll, when prudent, which will increase the expected number of points further. Though it still doesn't make sense to roll that last die, it's much closer than it appeared at face value.

In summary, at any point in time, we are making a decision about whether to continue rolling the dice or stop. The key parameters are (1) how many points we have in the bank and (2) how many dice are remaining. A good decision will be based not just on what might happen this roll but also on subsequent rolls. We are going to make a Shiny app to help us make these decisions.

Building & Deploying Our Shiny App

I started by doing a little up-front work to generate the state space of possible rolls. This computation is not reactive and only needs to be performed once prior to app initialization. Next, I created a recursive play function which determines the optional strategy (roll or stop) with parameters for how many points have been banked so far and how many dice are remaining. I gave the function a max recursion depth to limit computation time. I figured this is okay since (1) turns with a large number of rolls are quite improbable and thus contribute less to our decision making and (2) players become increasingly less likely to continue rolling as they accumulate more points since they have more to lose. Finally, I made the Shiny app. Building Shiny apps involves laying out inputs, outputs, and the logic that ties them together. This app is very simple. Just 26 lines of R!

On GCP, one option for deploying our Shiny app is spinning up a Compute Instance, installing all the necessary software (R, Shiny server, and other necessary R packages), and downloading the code for our app. A more organized approach is to instead use a container like Docker. Fundamentally, containers allow developers to separate applications from the environments in which they run. Containers package an application and its dependencies into a single manifest (that can be version controlled) that runs directly on top of an OS kernel.

Firstly, we need to setup a Dockerfile for our Docker image. I extended this image which installs R and Shiny Server. After that, I simply copy my app into the correct directory and do some initialization.

# start with image with R and Shiny server installed
FROM rocker/shiny

# copy files into correct directories
COPY ./shiny/ /srv/shiny-server/farkle/
RUN mv /srv/shiny-server/farkle/shiny-server.conf /etc/shiny-server/shiny-server.conf

# initialize some inputs for the app
WORKDIR /srv/shiny-server/farkle/
RUN mkdir -p data && \
  R -e "install.packages(c('dplyr'), repos='http://cran.rstudio.com/')" && \
  Rscript init.R

Next, a few commands to make our Docker image and verify that it works:

docker build -t farkle:latest .
docker run --rm -p 3838:3838 farkle:latest # test that it works locally

Then tag the image and push it to Google Container Repository:

export PROJECT_ID=$(gcloud config get-value project -q)
docker tag farkle gcr.io/${PROJECT_ID}/shiny-farkle:latest
gcloud docker -- push gcr.io/${PROJECT_ID}/shiny-farkle
gcloud container images list-tags gcr.io/${PROJECT_ID}/shiny-farkle

Finally, we're going to deploy this image on Google Kubernetes Engine. I used Terraform to define and create the GCP infrastructure components for this project: a Kubernetes clusters and a global static IP. Finally, we apply a Kubernetes manifest containing a deployment for our image and a service, connected to the static IP, to make the service externally accessible. I packaged my Kubernetes resources in a Helm chart, which you can use to inject values / variables via template directives (e.g. {{ ... }}).

terraform apply -var project=${PROJECT_ID}

gcloud container clusters get-credentials shiny-cluster
gcloud config set container/cluster shiny-cluster

helm init
helm install . --set projectId=${PROJECT_ID}

Terraform configuration:

variable "project" {}

variable "region" {
  default = "us-central1"
}

variable "zone" {
  default = "us-central1-f"
}

provider "google" {
  version = "~> 1.4"
  project = "${var.project}"
  region = "${var.region}"
}

resource "google_compute_global_address" "shiny-static-ip" {
  name = "shiny-static-ip"
}

resource "google_container_cluster" "shiny-cluster" {
  name = "shiny-cluster"
  zone = "${var.zone}"
  initial_node_count = "1"
  node_config {
    machine_type = "n1-standard-1"
    oauth_scopes = ["https://www.googleapis.com/auth/devstorage.read_only"]
  }
}

Kubernetes manifest:

---
apiVersion: v1
kind: Service
metadata:
  name: shiny-farkle-service
  annotations:
    kubernetes.io/ingress.global-static-ip-name: shiny-static-ip
  labels:
    app: shiny-farkle
spec:
  type: LoadBalancer
  ports:
  - port: 80
    targetPort: 3838
  selector:
    app: shiny-farkle
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: shiny-farkle-deploy
  labels:
    app: shiny-farkle
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: shiny-farkle
    spec:
      containers:
      - name: master
        imagePullPolicy: Always
        image: gcr.io/{{ .Values.projectId }}/shiny-farkle:latest
        ports:
        - containerPort: 3838

Some Final Farkle Insights

The really important question is this: if I have X dice remaining, how many points must I have in the bank to NOT roll? Using our Shiny app (3 roll look-forward), I estimated these numbers:

Dice RemainingBank Threshold to Stop Rolling
1262
2225
3386
4944
52,766
616,785

The game is played to 10,000 points (with the lagging player getting a rebuttal opportunity). So there is virtually no scenario in which you would not roll 6 dice when given the opportunity! You can find a link to all of my work here and a link to the app deployed using this methodology here Cheers!

Note #1: A big simplification that I make on game play is that all dice that can be scored will be scored. In reality, players have the option not to score all dice. For instance, if I roll three 1s, I can choose to bank one 1 and roll the 5 remaining dice, which, using our app, makes sense. 100 in the bank and 5 remaining dice has a 352.0 expectation; 200 in the bank and 4 dice remaining has a 329.0 expectation.

Note #2: Of course, these estimates are agnostic to the game situation. In reality, you're trying to maximize your probability of winning, not your expected number of points. If you are down by 5000 points, you're going to need to be a lot more aggressive.

---

02-10-2018 Update: I moved hosting of this app to Now for purely financial reasons. They provide serverless deployments for Node.js and Docker. They also provide 3 instances for free!

How to Stream Raw Google Analytics Data into BigQuery

19 September ’17

08-13-2018 Update: As of 07-24-2018, you can now write Google Cloud Functions in Python! I re-wrote the Cloud Function in this post in Python.

I have been using Google Analytics for a while for my own projects. The Google Analytics interface is great for helping you track activity on your site at a high-level. However, there are some cases in which having access to raw GA events may be helpful. For instance, maybe you record a unique identifier in the user_id parameters and want to tie Google Analytics activity to data in another system, e.g. transactions. So I set up a simple process to stream my GA events into BigQuery.

First, I created a Google Cloud Function to receive these events and ingest them into BigQuery:

# streams google analytics data into bigquery
def ingest_ga(request):
    import datetime
    import flask
    from google.cloud import bigquery

    mapping = {
        'version': 'v',
        'tracking_id': 'tid',
        'document_location': 'dl',
        'hit_type': 't',
        'user_id': 'uid',
        'client_id': 'cid',
        'user_language': 'ul',
        'event_category': 'ec',
        'event_action': 'ea',
        'event_label': 'el',
        'event_value': 'ev'
    }

    client = bigquery.Client()
    table_ref = client.dataset('google_analytics').table('events')
    table = client.get_table(table_ref)

    row = {k: request.args.get(v) for k, v in mapping.items()}
    row['timestamp'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')

    errors = client.insert_rows(table, [row])
    if len(errors) > 0:
        raise RuntimeError(errors[0]['errors'])

    resp = flask.make_response()
    resp.headers.add('Access-Control-Allow-Origin', '*')

    return resp

Next, I added some client-side JavaScript to also call my Cloud Function when uploading events to Google Analytics, effectively piggybacking the 'sendHitTask' task:

function RouteGAData(tracker) {

    ga(function(tracker) {
        var originalSendHitTask = tracker.get('sendHitTask');
        tracker.set('sendHitTask', function(model) {
            var payLoad = model.get('hitPayload');
            originalSendHitTask(model);
            var routeRequest = new XMLHttpRequest();
            var routePath = "https://REGION-PROJECT.cloudfunctions.net/ingestGA";
            routeRequest.open('GET', routePath + "?" + payLoad, true);
            routeRequest.send();
        });
    });

}
ga('provide', 'ga_route_plugin', RouteGAData);

A few additional notes:

  • Google Analytics has a lot of parameters that can be set! They are detailed here. My code is only syncing a specific subset of these that I care about. You will need to edit this and the schema for your table in BigQuery if you want to track additional fields.
  • To allow your domain to make requests to region-project.cloudfunctions.net/ingest_ga, I added a Access-Control-Allow-Origin: * header to the Cloud Function response, thus enabling Cross-Origin Resource Sharing (CORS).
  • If loading client-side JS as a GA plug-in, ga('require', 'ga_route_plugin') must come after the ga('create', ...) command and before the ga('send', 'pageview') command. Also make sure to update the REGION and PROJECT values.

You can check out all my entire code and more detailed set-up instructions here. Cheers!

Add Some Game Theory to Your Fantasy Football Draft

31 August ’17

Does your projected starting quarterback have an 18-42 starting record? Did your team decide to complement its crazy wide receiver with...another crazy wide receiver? Did your team sign Mike Glennon, who has a 5-13 starting record, to a $43.5M deal because he's...tall, then trade the #3 pick, a third round pick (#67), a fourth round pick (#111) and a 2018 third round pick TO MOVE UP 1 SPOT to draft Mitch Trubisky, who probably would have been available at #3 anyways? Did your team maintain its SB-winning core, add a top 10 wide receiver, add smoking Jay Cutler to the division for 2 easy wins, and remain the clear favorite to repeat? It doesn't matter. You'll always have fantasy football. And when all your starters get injured, you'll have daily fantasy football (eh, maybe not).

In any case, fantasy football is incredibly fun. The most important part of course being the draft. For quite a long time, the table stakes for draft strategy has been value-based drafting (VBD), pioneered by Joe Bryant of Footballguys.com in 2001. The core idea behind VBD is that a player’s value isn’t based on how many absolute points he scores, but rather how many points he scores relative to a "baseline" player at his position. The most common strategy for establishing the baseline is to compare each player to the last starting player at that position (Value Over Last Starter of VOLS). Let's say we have a 10 person league with standard rules (1 starting QB, 2 starting RB, 3 starting WR, 1 TE, 1 Flex, 1 DEF). Quarterbacks are compared to the 10th best quarterback, RBs are compared to the 20th best RB, WRs are compared to the 30th best WR, etc.

Source: NumberFire

So if VBD is so good, then why is our worst fear forgetting about the draft and letting autodraft (which uses a VBD-ordered player list) pick our team? A few things:

  • There is not consensus on player value - There are lots of places to get player projects. Certain players are really hard to project, like rookies. In other cases, people may not feel that projections reflect injury risk.
  • It does not take into account where we are in the draft - The descent curve between the best player and the replacement player isn't smooth; it has kinks and plateaus. Plus, it doesn't often make sense to take whatever person is at the top of the VBD heap. Do you really want to draft another QB before you have 3 starting WR?
  • People aren't good at committing to a strategy - People get attached to specific players. They jump the gun to draft players they like even if it isn't the logical choice.

Actual draft behavior looks more like this:

Source: NumberFire

The peaks and troughs in the above graph represent opportunity! We can derive value by anticipating what our opponents are going to do. It makes more sense to have a strategy that adjusts dynamically based on where we are in the draft: what players we need, what players our opponents need, and what players are still remaining.

Improvement #1: Value Over Next Available (VONA)

Instead of using the last starter as our baseline, we can use the value of the next available player at that position. If it's early in the draft, maybe I think 5 RBs and 5 WRs will come off the board by the time the draft snakes back to me. I can use this information to determine which position will suffer the steepest drop off and draft accordingly.

Improvement #2: Incorporating Average Draft Position (ADP)

Next, we can use ADP data to estimate how people are going to deviate from our projections. For instance, T.Y. Hilton is #14 on my VBD draft board. However, on average, he is being drafted #28. Though I would be content with T.Y. Hilton at #14, maybe I instead draft someone else because I think that T.Y. Hilton will still be available by the time the draft snakes back to me.

I put together a small Google Spreadsheet to help me manage my drafts. You can check it out here. Just don't share it with anyone in my league. Cheers!