Analysis of Health Inspection Data using F#

As part of the TRINUG F#/Analytics SIG, I did a public records request from Wake County for all of the restaurant inspections in 2013.  If you are not familiar, the inspectors go out and then give a score to the restaurant.  The restaurant then has to display their score like this:

image

After some back and forth, I got the data as an Excel spreadsheet that looks like this

image

I then loaded the spreadsheet into a sql server and exposed it as some OData endpoints.

  1. // GET odata/Restaurant
  2. [Queryable]
  3. public IQueryable<Restaurant> GetRestaurant()
  4. {
  5.     return db.Restaurants;
  6. }
  7.  
  8. // GET odata/Restaurant(5)
  9. [Queryable]
  10. public SingleResult<Restaurant> GetRestaurant([FromODataUri] int key)
  11. {
  12.     return SingleResult.Create(db.Restaurants.Where(restaurant => restaurant.Id == key));
  13. }

I then dove into the data to see if there were any interesting conclusions to be found.  Following my pattern of doing analytics using F# and unit testing using C#, I created a project with the following code:

  1. namespace ChickenSoftware.RestraurantChicken.Analysis
  2.  
  3. open System.Linq
  4. open System.Configuration
  5. open Microsoft.FSharp.Linq
  6. open Microsoft.FSharp.Data.TypeProviders
  7.  
  8. type internal SqlConnection = SqlEntityConnection<ConnectionStringName="azureData">
  9.  
  10. type public RestaurantAnalysis () =
  11.     
  12.     let connectionString = ConfigurationManager.ConnectionStrings.["azureData"].ConnectionString;

Note that I am using the connection string in two places – the 1st for the type provider to do its magic at design time and the second for actually accessing the data at run time.  With that set up, the 1st question I had was “ is there seasonality in inspection scores like there are in traffic tickets?”  To that end, I created the following function:

  1. member public x.GetAverageScoreByMonth () =
  2.     SqlConnection.GetDataContext(connectionString).Restaurants
  3.         |> Seq.map(fun x -> x.InspectionDate.Value.Month, x.InspectionScore.Value)
  4.         |> Seq.groupBy(fun x -> fst x)
  5.         |> Seq.map(fun (x,y) -> (x,y |> Seq.averageBy snd))
  6.         |> Seq.map(fun (x,y) -> x, System.Math.Round(y,2))
  7.         |> Seq.toArray
  8.         |> Array.sort

This is pretty vanilla F# code, with the tricky part being the average by month (lines 4 and 5 here).  What the code is doing is grouping up the 4,000 or so tuples that were created on line 3 into another tuple – with the fst being the groupBy value (in this case month) and then the second tuple being a tuple with the month and score.  Then, by averaging up the score of the second tuple, we get an average for each month.  I create a unit (really integration) test like so:

  1. [TestMethod]
  2. public void GetAverageScoreByMonth_ReturnsTwelveItems()
  3. {
  4.     var analysis = new RestaurantAnalysis();
  5.     var scores = analysis.GetAverageScoreByMonth();
  6.     Int32 expected = 12;
  7.     Int32 actual = scores.Length;
  8.     Assert.AreEqual(expected, actual);
  9. }

And the result ran green. 

image

Putting a break on the Assert and a watch on scores, you can see the values:

image

A couple of things stand out

1) The overall average is around 96 and change

2) There does not seem to be any significant variance among months.

Since I am trying to also teach myself D3, I then added a MVC5 project to my solution and added an analysis controller that calls the function in the analysis module and serves the results as json:

  1. public JsonResult AverageScoreByMonth()
  2. {
  3.     var analysis = new RestaurantAnalysis();
  4.     var scores = analysis.GetAverageScoreByMonth();
  5.     return Json(scores,JsonRequestBehavior.AllowGet);
  6. }

I then made a page with a simple D3 chart that calls this controller

  1. @{
  2.     Layout = "~/Views/Shared/_Layout.cshtml";
  3. }
  4.  
  5. <svg class="chart"></svg>
  6.  
  7. <style>
  8.     .bar {
  9.         fill: steelblue;
  10.     }
  11.  
  12.         .bar:hover {
  13.             fill: brown;
  14.         }
  15.  
  16.     .axis {
  17.         font: 10px sans-serif;
  18.     }
  19.  
  20.         .axis path,
  21.         .axis line {
  22.             fill: none;
  23.             stroke: #000;
  24.             shape-rendering: crispEdges;
  25.         }
  26.  
  27.     .x.axis path {
  28.         display: none;
  29.     }
  30. </style>
  31.  
  32.  
  33.  
  34. <script>
  35.  
  36.     var margin = { top: 20, right: 20, bottom: 30, left: 40 },
  37.         width = 960 – margin.left – margin.right,
  38.         height = 500 – margin.top – margin.bottom;
  39.  
  40.     var x = d3.scale.ordinal()
  41.         .rangeRoundBands([0, width], .1);
  42.  
  43.     var y = d3.scale.linear()
  44.         .range([height, 0]);
  45.  
  46.     var xAxis = d3.svg.axis()
  47.         .scale(x)
  48.         .orient("bottom");
  49.  
  50.     var yAxis = d3.svg.axis()
  51.         .scale(y)
  52.         .orient("left")
  53.         .ticks(10, "%");
  54.  
  55.     var svg = d3.select("body").append("svg")
  56.         .attr("width", width + margin.left + margin.right)
  57.         .attr("height", height + margin.top + margin.bottom)
  58.       .append("g")
  59.         .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
  60.  
  61.  
  62.  
  63.     $.ajax({
  64.         url: "http://localhost:3057/Analysis/AverageScoreByMonth/&quot;,
  65.         dataType: "json",
  66.         success: function (data) {
  67.             x.domain(data.map(function (d) { return d.Item1; }));
  68.             y.domain([0, d3.max(data, function (d) { return d.Item2; })]);
  69.  
  70.             svg.append("g")
  71.                 .attr("class", "x axis")
  72.                 .attr("transform", "translate(0," + height + ")")
  73.                 .call(xAxis);
  74.  
  75.             svg.append("g")
  76.                 .attr("class", "y axis")
  77.                 .call(yAxis)
  78.               .append("text")
  79.                 .attr("transform", "rotate(-90)")
  80.                 .attr("y", 6)
  81.                 .attr("dy", ".71em")
  82.                 .style("text-anchor", "end")
  83.                 .text("Frequency");
  84.  
  85.             svg.selectAll(".bar")
  86.                 .data(data)
  87.               .enter().append("rect")
  88.                 .attr("class", "bar")
  89.                 .attr("x", function (d) { return x(d.Item1); })
  90.                 .attr("width", x.rangeBand())
  91.                 .attr("y", function (d) { return y(d.Item2); })
  92.                 .attr("height", function (d) { return height – y(d.Item2); });
  93.  
  94.         },
  95.         error: function (e) {
  96.             alert("error");
  97.         }
  98.     });
  99.  
  100.     function type(d) {
  101.         d.Item2 = +d.Item2;
  102.         return d;
  103.     }
  104. </script>

And when I run it, a run-of-the mill barchart (I did have to adjust the F# to shift the decimal to the left two positions so that I could match the scale of the chart’s template.  For me, it is easier to alter the F# than the javascript:

image

Following this pattern, I did some other season analysis like average by DayOfMonth

image

DayOf Week.

image

So there does not seem to be any seasonality in inspection scores.

I then did an average of inspectors

image

And there looks to be some variance, but it is getting lost of the scale of the map.  The problem is that the range of the scores is not 0 to 100

Here is a function that counts the number of scores (rounded to 0)

  1. member public x.CountOfRoundedScores () =
  2.     SqlConnection.GetDataContext(connectionString).Restaurants
  3.         |> Seq.map(fun x -> System.Math.Round(x.InspectionScore.Value,0), x.InspectionID)
  4.         |> Seq.groupBy(fun x -> fst x)
  5.         |> Seq.map(fun (x,y) -> (x,y |> Seq.countBy snd))
  6.         |> Seq.map(fun (x,y) -> (x,y |> Seq.sumBy snd))
  7.         |> Seq.toArray

That graphically looks like:

image

So back to inspectors, I needed to adjust the scale from 0 to 100 to 80 to 100.  I also needed to remove the null inspection Ids and the records that were for the ‘test facility’ and the 6 records that were below 80.

  1. member public x.AverageScoreByInspector () =
  2.     SqlConnection.GetDataContext(connectionString).Restaurants
  3.         |> Seq.filter(fun x -> x.EstablishmentName <> "Test Facility")
  4.         |> Seq.filter(fun x -> x.InspectionScore.Value > 80.)
  5.         |> Seq.filter(fun x -> x.InspectionID <> null)
  6.         |> Seq.map(fun x -> x.InspectorID, x.InspectionScore.Value)
  7.         |> Seq.groupBy(fun x -> fst x)
  8.         |> Seq.map(fun (x,y) -> (x,y |> Seq.averageBy snd))
  9.         |> Seq.map(fun (x,y) -> x, y/100.)
  10.         |> Seq.map(fun (x,y) -> x, System.Math.Round(y,4))
  11.         |> Seq.toArray
  12.         |> Array.sort

I then adjusted the scale of the inspector graph to have to domain from 80 to 100 (versus 0 to 100) and the scale of the y axis.  This was a good article explaining Scales and Domains in D3.

  1. var yAxis = d3.svg.axis()
  2.     .scale(y)
  3.     .orient("left")
  4.     .ticks(10);

  1. $.ajax({
  2.     url: "http://localhost:3057/Analysis/AverageScoreByInspector/&quot;,
  3.     dataType: "json",
  4.     success: function (data) {
  5.         x.domain(data.map(function (d) { return d.Item1; }));
  6.         y.domain([80, d3.max(data, function (d) { return d.Item2; })]);

and now there is pretty good graph showing the variance among inspectors:

image

So the interesting this is that #1168 is 2 below the average – which of a domain of 10 is pretty significant.  Interestingly, 1168 is also the inspector who has all of the “Test facility” records – so they are probably the trainer and/or lead inspector.  With this analysis in the back pocket, ran a function that did the inspection score by establishment type:

image

This is kinda interesting (esp that pushcarts got the highest scores) but I wanted to see if there was any truth the the common perception that Chinese restaurants are less sanitary than other kinds of restaurants.  To that end, I created a rudimentary classifier that searched the name of the establishment to see if it had a name that is typically associated with fast-food Chinese:

  1. member public x.IsEstablishmentAChineseRestraurant (establishmentName:string) =
  2.     let upperCaseEstablishmentName = establishmentName.ToUpper()
  3.     let numberOfMatchedWords = upperCaseEstablishmentName.Split(' ')
  4.                                 |> Seq.map(fun x -> match x with
  5.                                                         | "ASIA" -> 1
  6.                                                         | "ASIAN" -> 1
  7.                                                         | "CHINA" -> 1
  8.                                                         | "CHINESE" -> 1
  9.                                                         | "PANDA" -> 1
  10.                                                         | "PEKING" -> 1
  11.                                                         | "WOK" -> 1
  12.                                                         | _ -> 0)
  13.                                 |> Seq.sum
  14.     match numberOfMatchedWords with
  15.         | 0 -> false
  16.         | _ -> true

I then created a function that returned the average and ran my unit tests.

  1. [TestMethod]
  2. public void IsEstablishmentAChineseRestraurantUsingWOK_ReturnsTrue()
  3. {
  4.     var analysis = new RestaurantAnalysis();
  5.     String establishmentName = "JAMIE'S WOK";
  6.  
  7.     var expected = true;
  8.     var actual = analysis.IsEstablishmentAChineseRestraurant(establishmentName);
  9.     Assert.AreEqual(expected, actual);
  10. }
  11.  
  12. [TestMethod]
  13. public void IsEstablishmentAChineseRestraurantUsingWok_ReturnsTrue()
  14. {
  15.     var analysis = new RestaurantAnalysis();
  16.     String establishmentName = "Jamie's Wok";
  17.  
  18.     var expected = true;
  19.     var actual = analysis.IsEstablishmentAChineseRestraurant(establishmentName);
  20.     Assert.AreEqual(expected, actual);
  21. }
  22.  
  23. [TestMethod]
  24. public void AverageScoreForChineseRestaurants_ReturnsExpected()
  25. {
  26.     var analysis = new RestaurantAnalysis();
  27.     var actual = analysis.AverageScoreForChineseRestaurants();
  28.     Assert.IsNotNull(actual);
  29. }

When a break was put on the value of the average, it was apparent that Chinese restaurants scored significantly lower than the average of 96

image

So then I applied 1 more segmentation: Chinese versus Non-Chinese scores by inspector:

  1. member public x.AverageScoresOfChineseAndNonChineseByInspector () =
  2.     let dataSet = SqlConnection.GetDataContext(connectionString).Restaurants
  3.                     |> Seq.map(fun x -> x.EstablishmentName, x.InspectorID,x.InspectionScore.Value)
  4.     let chineseRestraurants = dataSet
  5.                                 |> Seq.filter(fun (a,b,c) -> x.IsEstablishmentAChineseRestraurant(a))
  6.                                 |> Seq.map(fun (a,b,c) -> b,c)
  7.                                 |> Seq.groupBy(fun x -> fst x)
  8.                                 |> Seq.map(fun (x,y) -> (x,y |> Seq.averageBy snd))
  9.                                 |> Seq.map(fun (x,y) -> x, System.Math.Round(y,2))
  10.                                 |> Seq.toArray
  11.                                 |> Array.sort
  12.     let nonChineseRestraurants = dataSet
  13.                                 |> Seq.filter(fun (a,b,c) -> not(x.IsEstablishmentAChineseRestraurant(a)))
  14.                                 |> Seq.map(fun (a,b,c) -> b,c)
  15.                                 |> Seq.groupBy(fun x -> fst x)
  16.                                 |> Seq.map(fun (x,y) -> (x,y |> Seq.averageBy snd))
  17.                                 |> Seq.map(fun (x,y) -> x, System.Math.Round(y,2))
  18.                                 |> Seq.toArray
  19.                                 |> Array.sort
  20.     Seq.zip chineseRestraurants nonChineseRestraurants
  21.            |> Seq.map(fun ((a,b),(c,d)) -> a,b,d)
  22.            |> Seq.toList

And in graphics using a double-bar chart:

image

So this is kinda interesting.  The lead inspector (1168) who grades everyone lower actually gives Chinese restaurants higher marks.  Everyone else pretty much grades Chinese restaurants lower except for 1 inspector.  Also, 1708 must really not like Chinese restaurants – or their inspection list has a series of really bad Chinese restaurants.

Note that this may not be statistically significant (I didn’t control for sample size, etc..) – but further analysis might be warranted, no?  If you are interested, here is the endpoint: http://restaurantchicken.cloudapp.net/odata/Restaurant

Finally, when I presented this analysis to TRINUG last week, lots of people became interested in F# and analytics (ok, maybe 3).  You can see the comments here.  Also, I now have an appointment with the head of the health department department and the CIO of Wake County later this week – let’s see what they say…

 

 

Advertisements

Traffic Stop Visualization Using D3

One of the comments I got from the TRINUG Data SIG was that the data and analysis were exciting but the results were, well, boring.  So I went back to the traffic stop data and thought about how I could sexy it up.  Since lots of people have been using D3 to present the data. I thought it would be a good place to start.

My 1st step was to look at their samples page and they have a simple bar chart that seems like a good introduction to the library.  I created an endpoint on my webapi for the summary data like so:

  1. [HttpGet]
  2. [Route("api/TrafficStopSearch/StopsByMonth/")]
  3. public dynamic StopsByMonth()
  4. {
  5.     return ChickenSoftware.RoadAlert.Analysis.AnalysisEngine.TrafficStopsByMonth;
  6.  
  7. }

I then spun up an empty asp.net website and created an index page based on their sample.  I then added an ajax call to the controller and replaced the reference to the data.tsv file:

  1. $.ajax({
  2.    url: "http://localhost:17680/api/TrafficStopSearch/StopsByMonth/&quot;,
  3.    dataType: "json",
  4.    success: function (data) {
  5.        x.domain(data.map(function (d) { return d.m_Item1; }));
  6.        y.domain([0, d3.max(data, function (d) { return d.m_Item6; })]);
  7.  
  8.        svg.append("g")
  9.            .attr("class", "x axis")
  10.            .attr("transform", "translate(0," + height + ")")
  11.            .call(xAxis);
  12.  
  13.        svg.append("g")
  14.            .attr("class", "y axis")
  15.            .call(yAxis)
  16.          .append("text")
  17.            .attr("transform", "rotate(-90)")
  18.            .attr("y", 6)
  19.            .attr("dy", ".71em")
  20.            .style("text-anchor", "end")
  21.            .text("Frequency");
  22.  
  23.        svg.selectAll(".bar")
  24.            .data(data)
  25.          .enter().append("rect")
  26.            .attr("class", "bar")
  27.            .attr("x", function (d) { return x(d.m_Item1); })
  28.            .attr("width", x.rangeBand())
  29.            .attr("y", function (d) { return y(d.m_Item6); })
  30.            .attr("height", function (d) { return height – y(d.m_Item6); });
  31.        
  32.    },
  33.    error: function(e){
  34.        alert("error");
  35.    }
  36. });

On thing to note is that the tuple that was created in F# and then passed though via the C# controller had its name changed.  Specially, Tuple.Item1 became Tuple.m_Item1.  I think that passing out tuple.anything is a horrible idea, so I created a POCO that actually lets the consumer know what each field means:

  1. public class OutputValue
  2. {
  3.     public Int32 Month { get; set; }
  4.     public Int32 ExpectedStops { get; set; }
  5.     public Int32 ActualStops { get; set; }
  6.     public Double DifferenceBetweenExpectedAndActual { get; set; }
  7.     public Double PercentDifferenceBetweenExpectedAndActual { get; set; }
  8.     public Double Frequency { get; set; }
  9. }

and then I adjusted the controller like so:

  1.  
  2. [HttpGet]
  3. [Route("api/TrafficStopSearch/StopsByMonth/")]
  4. public dynamic StopsByMonth()
  5. {
  6.     var outputs = new List<OutputValue>();
  7.     var resultSet= ChickenSoftware.RoadAlert.Analysis.AnalysisEngine.TrafficStopsByMonth;
  8.     foreach (var tuple in resultSet)
  9.     {
  10.         var outputValue = new OutputValue()
  11.         {
  12.             Month = tuple.Item1,
  13.             ExpectedStops = tuple.Item2,
  14.             ActualStops = tuple.Item3,
  15.             DifferenceBetweenExpectedAndActual = tuple.Item4,
  16.             PercentDifferenceBetweenExpectedAndActual = tuple.Item5,
  17.             Frequency = tuple.Item6
  18.         };
  19.         outputs.Add(outputValue);
  20.     }
  21.  
  22.     return outputs;
  23. }

So I adjusted the javascript and voila: a bar chart:

image

Up next – some real charts…