From c37488195f18ce3338181746c52310ad10d28bcd Mon Sep 17 00:00:00 2001 From: Mark Seemann Date: Fri, 2 Feb 2024 20:40:33 +0100 Subject: [PATCH] Add article: Extracting CSV data with Haskell --- ...ta-from-a-small-csv-file-with-haskell.html | 304 ++++++++++++++++++ content/binary/difference-pmf-plot.png | Bin 0 -> 13947 bytes 2 files changed, 304 insertions(+) create mode 100644 _posts/2024-02-02-extracting-data-from-a-small-csv-file-with-haskell.html create mode 100644 content/binary/difference-pmf-plot.png diff --git a/_posts/2024-02-02-extracting-data-from-a-small-csv-file-with-haskell.html b/_posts/2024-02-02-extracting-data-from-a-small-csv-file-with-haskell.html new file mode 100644 index 00000000..dd47e33c --- /dev/null +++ b/_posts/2024-02-02-extracting-data-from-a-small-csv-file-with-haskell.html @@ -0,0 +1,304 @@ +--- +layout: post +title: "Extracting data from a small CSV file with Haskell" +description: "Statically typed languages are also good for ad-hoc scripting." +date: 2024-02-02 12:49 UTC +tags: [Haskell] +image: "/content/binary/difference-pmf-plot.png" +image_alt: "Bar chart of the differences PMF." +--- +{% include JB/setup %} + +
+

+ {{ page.description }} +

+

+ This article is part of a short series of articles that compares ad-hoc scripting in Haskell with solving the same problem in Python. The introductory article describes the problem to be solved, so here I'll jump straight into the Haskell code. In the next article I'll give a similar walkthrough of my Python script. +

+

+ Getting started # +

+

+ When working with Haskell for more than a few true one-off expressions that I can type into GHCi (the Haskell REPL), I usually create a module file. Since I'd been asked to crunch some data, and I wasn't feeling very imaginative that day, I just named the module (and the file) Crunch. After some iterative exploration of the problem, I also arrived at a set of imports: +

+

+

module Crunch where
+ 
+import Data.List (sort)
+import qualified Data.List.NonEmpty as NE
+import Data.List.Split
+import Control.Applicative
+import Control.Monad
+import Data.Foldable
+

+

+ As we go along, you'll see where some of these fit in. +

+

+ Reading the actual data file, however, can be done with just the Haskell Prelude: +

+

+

inputLines = words <$> readFile "survey_data.csv"
+

+

+ Already now, it's possible to load the module in GHCi and start examining the data: +

+

+

ghci> :l Crunch.hs
+[1 of 1] Compiling Crunch           ( Crunch.hs, interpreted )
+Ok, one module loaded.
+ghci> length <$> inputLines
+38
+

+

+ Looks good, but reading a text file is hardly the difficult part. The first obstacle, surprisingly, is to split comma-separated values into individual parts. For some reason that I've never understood, the Haskell base library doesn't even include something as basic as String.Split from .NET. I could probably hack together a function that does that, but on the other hand, it's available in the split package; that explains the Data.List.Split import. It's just such a bit of a bother that one has to pull in another package just to do that. +

+

+ Grades # +

+

+ Extracting all the grades are now relatively easy. This function extracts and parses a grade from a single line: +

+

+

grade :: Read a => String -> a
+grade line = read $ splitOn "," line !! 2
+

+

+ It splits the line on commas, picks the third element (zero-indexed, of course, so element 2), and finally parses it. +

+

+ One may experiment with it in GHCi to get an impression that it works: +

+

+

ghci> fmap grade <$> inputLines :: IO [Int]
+[2,2,12,10,4,12,2,7,2,2,2,7,2,7,2,4,2,7,4,7,0,4,0,7,2,2,2,2,2,2,4,4,2,7,4,0,7,2]
+

+

+ This lists all 38 expected grades found in the data file. +

+

+ In the introduction article I spent some time explaining how languages with strong type inference don't need type declarations. This makes iterative development easier, because you can fiddle with an expression until it does what you'd like it to do. When you change an expression, often the inferred type changes as well, but there's no programmer overhead involved with that. The compiler figures that out for you. +

+

+ Even so, the above grade function does have a type annotation. How does that gel with what I just wrote? +

+

+ It doesn't, on the surface, but when I was fiddling with the code, there was no type annotation. The Haskell compiler is perfectly happy to infer the type of an expression like +

+

+

grade line = read $ splitOn "," line !! 2
+

+

+ The human reader, however, is not so clever (I'm not, at least), so once a particular expression settles, and I'm fairly sure that it's not going to change further, I sometimes add the type annotation to aid myself. +

+

+ When writing this, I was debating the didactics of showing the function with the type annotation, against showing it without it. Eventually I decided to include it, because it's more understandable that way. That decision, however, prompted this explanation. +

+

+ Binomial choice # +

+

+ The next thing I needed to do was to list all pairs from the data file. Usually, when I run into a problem related to combinations, I reach for applicative composition. For example, to list all possible combinations of the first three primes, I might do this: +

+

+

ghci> liftA2 (,) [2,3,5] [2,3,5]
+[(2,2),(2,3),(2,5),(3,2),(3,3),(3,5),(5,2),(5,3),(5,5)]
+

+

+ You may now protest that this is sampling with replacement, whereas the task is to pick two different rows from the data file. Usually, when I run into that requirement, I just remove the ones that pick the same value twice: +

+

+

ghci> filter (uncurry (/=)) $ liftA2 (,) [2,3,5] [2,3,5]
+[(2,3),(2,5),(3,2),(3,5),(5,2),(5,3)]
+

+

+ That works great as long as the values are unique, but what if that's not the case? +

+

+

ghci> liftA2 (,) "foo" "foo"
+[('f','f'),('f','o'),('f','o'),('o','f'),('o','o'),('o','o'),('o','f'),('o','o'),('o','o')]
+ghci> filter (uncurry (/=)) $ liftA2 (,) "foo" "foo"
+[('f','o'),('f','o'),('o','f'),('o','f')]
+

+

+ This removes too many values! We don't want the combinations where the first o is paired with itself, or when the second o is paired with itself, but we do want the combination where the first o is paired with the second, and vice versa. +

+

+ This is relevant because the data set turns out to contain identical rows. Thus, I needed something that would deal with that issue. +

+

+ Now, bear with me, because it's quite possible that what i did do isn't the simplest solution to the problem. On the other hand, I'm reporting what I did, and how I used Haskell to solve a one-off problem. If you have a simpler solution, please leave a comment. +

+

+ You often reach for the tool that you already know, so I used a variation of the above. Instead of combining values, I decided to combine row indices instead. This meant that I needed a function that would produce the indices for a particular list: +

+

+

indices :: Foldable t => t a -> [Int]
+indices f = [0 .. length f - 1]
+

+

+ Again, the type annotation came later. This just produces sequential numbers, starting from zero: +

+

+

ghci> indices <$> inputLines
+[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,
+ 21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37]
+

+

+ Such a function hovers just around the Fairbairn threshold; some experienced Haskellers would probably just inline it. +

+

+ Since row numbers (indices) are unique, the above approach to binomial choice works, so I also added a function for that: +

+

+

choices :: Eq a => [a] -> [(a, a)]
+choices = filter (uncurry (/=)) . join (liftA2 (,))
+

+

+ Combined with indices I can now enumerate all combinations of two rows in the data set: +

+

+

ghci> choices . indices <$> inputLines
+[(0,1),(0,2),(0,3),(0,4),(0,5),(0,6),(0,7),(0,8),(0,9),...
+

+

+ I'm only showing the first ten results here, because in reality, there are 1406 such pairs. +

+

+ Perhaps you think that all of this seems quite elaborate, but so far it's only four lines of code. The reason it looks like more is because I've gone to some lengths to explain what the code does. +

+

+ Sum of grades # +

+

+ The above combinations are pairs of indices, not values. What I need is to use each index to look up the row, from the row get the grade, and then sum the two grades. The first parts of that I can accomplish with the grade function, but I need to do if for every row, and for both elements of each pair. +

+

+ While tuples are Functor instances, they only map over the second element, and that's not what I need: +

+

+

ghci> rows = ["foo", "bar", "baz"]
+ghci> fmap (rows!!) <$> [(0,1),(0,2)]
+[(0,"bar"),(0,"baz")]
+

+

+ While this is just a simple example that maps over the two pairs (0,1) and (0,2), it illustrates the problem: It only finds the row for each tuple's second element, but I need it for both. +

+

+ On the other hand, a type like (a, a) gives rise to a functor, and while a wrapper type like that is not readily available in the base library, defining one is a one-liner: +

+

+

newtype Pair a = Pair { unPair :: (a, a) } deriving (EqShowFunctor)
+

+

+ This enables me to map over pairs in one go: +

+

+

ghci> unPair <$> fmap (rows!!) <$> Pair <$> [(0,1),(0,2)]
+[("foo","bar"),("foo","baz")]
+

+

+ This makes things a little easier. What remains is to use the grade function to look up the grade value for each row, then add the two numbers together, and finally count how many occurrences there are of each: +

+

+

sumGrades ls =
+  liftA2 (,) NE.head length <$> NE.group
+    (sort (uncurry (+) . unPair . fmap (grade . (ls !!)) . Pair <$>
+      choices (indices ls)))
+

+

+ You'll notice that this function doesn't have a type annotation, but we can ask GHCi if we're curious: +

+

+

ghci> :t sumGrades
+sumGrades :: (Ord a, Num a, Read a) => [String] -> [(a, Int)]
+

+

+ This enabled me to get a count of each sum of grades: +

+

+

ghci> sumGrades <$> inputLines
+[(0,6),(2,102),(4,314),(6,238),(7,48),(8,42),(9,272),(10,6),
+ (11,112),(12,46),(14,138),(16,28),(17,16),(19,32),(22,4),(24,2)]
+

+

+ The way to read this is that the sum 0 occurs six times, 2 appears 102 times, etc. +

+

+ There's one remaining task to accomplish before we can produce a PMF of the sum of grades: We need to enumerate the range, because, as it turns out, there are sums that are possible, but that don't appear in the data set. Can you spot which ones? +

+

+ Using tools already covered, it's easy to enumerate all possible sums: +

+

+

ghci> import Data.List
+ghci> sort $ nub $ (uncurry (+)) <$> join (liftA2 (,)) [-3,0,2,4,7,10,12]
+[-6,-3,-1,0,1,2,4,6,7,8,9,10,11,12,14,16,17,19,20,22,24]
+

+

+ The sums -6, -3, -1, and more, are possible, but don't appear in the data set. Thus, in the PMF for two randomly picked grades, the probability that the sum is -6 is 0. On the other hand, the probability that the sum is 0 is 6/1406 ~ 0.004267, and so on. +

+

+ Difference of experience levels # +

+

+ The other question posed in the assignment was to produce the PMF for the absolute difference between two randomly selected students' experience levels. +

+

+ Answering that question follows the same mould as above. First, extract experience level from each data row, instead of the grade: +

+

+

experience :: Read a => String -> a
+experience line = read $ splitOn "," line !! 3
+

+

+ Since I was doing an ad-hoc script, I just copied the grade function and changed the index from 2 to 3. Enumerating the experience differences were also a close copy of sumGrades: +

+

+

diffExp ls =
+  liftA2 (,) NE.head length <$> NE.group
+    (sort (abs . uncurry (-) . unPair . fmap (experience . (ls !!)) . Pair <$>
+      choices (indices ls)))
+

+

+ Running it in the REPL produces some other numbers, to be interpreted the same way as above: +

+

+

ghci> diffExp <$> inputLines
+[(0,246),(1,472),(2,352),(3,224),(4,82),(5,24),(6,6)]
+

+

+ This means that the difference 0 occurs 246 times, 1 appears 472 times, and so on. From those numbers, it's fairly simple to set up the PMF. +

+

+ Figures # +

+

+ Another part of the assignment was to produce plots of both PMFs. I don't know how to produce figures with Haskell, and since the final results are just a handful of numbers each, I just copied them into a text editor to align them, and then pasted them into Excel to produce the figures there. +

+

+ Here's the PMF for the differences: +

+

+ Bar chart of the differences PMF. +

+

+ I originally created the figure with Danish labels. I'm sure that you can guess what differens means, and sandsynlighed means probability. +

+

+ Conclusion # +

+

+ In this article you've seen the artefacts of an ad-hoc script to extract and analyze a small data set. While I've spent quite a few words to explain what's going on, the entire Crunch module is only 34 lines of code. Add to that a few ephemeral queries done directly in GHCi, but never saved to a file. It's been some months since I wrote the code, but as far as I recall, it took me a few hours all in all. +

+

+ If you do stuff like this every day, you probably find that appalling, but data crunching isn't really my main thing. +

+

+ Is it quicker to do it in Python? Not for me, it turns out. It also took me a couple of hours to repeat the exercise in Python. +

+

+ Next: Extracting data from a small CSV file with Python. +

+
\ No newline at end of file diff --git a/content/binary/difference-pmf-plot.png b/content/binary/difference-pmf-plot.png new file mode 100644 index 0000000000000000000000000000000000000000..6461fd19b49985e8ef354715805ae1c9720893f5 GIT binary patch literal 13947 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

)j2cCqN%AV&{-h2@eMvjA^9z0V(w?p zuH9zwGT5|}{KF%82|k94fNauWT-py^)@p}R+OcwbQvelxX;QjadUFstNc1>d1TC&- z6}+s@V<%7Gmp(o|kTF@AtPupnsA(1DW^y0~s2i3Gs*Q@nBE!DA z$=4Ucz@vqhX7|Q+g;n~#zX|}`LN;2{4QB1Z;bA`0_8^0%7mAuYAhX6c z_RA&9!d|>il{+LNQdU;BP0;8i=^YqQA5do?>BEjaaKy-0_nkf`mpr;i5m>5JAWANI zkRwW+Eb@ z&~M)$D>#g~Y|I{GS$*&^6XFD6bC8n$5yHvI3Bnb?vJ^c>8yjd&tY@eTSRkbZKkzpb zU)tE%0FZ#YJlh@#G5N%iM>ktRrX4LbelZJ_4RkW3e0uNg5HUM&mH@heWY%$96MNn3 zpBKQz2%a5pC!QTYe&h;DW4Xg>IizVdt6X}1@o{lQ{!z z`O@jOj*jVSOFW*ZZooLD4u*ZzbJWX(eW4xpIsuc5+1G*?CrGk|bg&^NKT;Y2WTwh3 zLM#RH2@=aXlbCq8^@03_2B2FzQZqa(va4*4PHx8RWJ((t%z9rsVa2ZnI3plK(*_JH zUk8f$!q|U7`#Ru0y0Gl=lNG-B>{+|0S*=b$_J7Nn89|}S~@y@NG5WctBQ%WK&w7auf)eo^++~*fw3uz zS4AKuDf#0`B18${vqBA@hr$uc5wVxLs3EQ) zrU`%@W*^Kr$X*-8i0>FHGYUZF-A&Hdg+lJm@+!@2cZl1omvdsny2X0ojYW|@= zKB#W&uhXA`^KVz1M2c024JI$JIWlc9c^yUNoh&WapZ7#^Ct<8 literal 0 HcmV?d00001