Showing posts with label cloud. Show all posts
Showing posts with label cloud. Show all posts

Saturday, January 12, 2019

New Deployment Options in SoDA v2.x


I released version 2.0 of the Solr Dictionary Annotator (SoDA), the open source project hosted by Elsevier, sometime middle of last year. While some of the features were driven by user feedback from within the company and my general dissatisfaction with some of the results, a lot of the impetus for the release was due to the team at SWIFT Innovation Labs, who decided to use SoDA as part of their solution for Address Entity Resolution. Because the SWIFT team needed functionality which wasn't available in SoDA v1.x but seemed quite easy to include, I embarked on what ended up being a completely new version. I am also grateful to the SWIFT team for feedback around new and existing (but rewritten) functionality, and for including me as co-author for the paper they are in the process of publishing around their work.

From the outside, the changes in SoDA version 2.0 are mostly evolutionary in nature. The biggest one is the removal of the non-streaming API and the addition of 3 new matching modes to the streaming API. I removed the non-streaming API because annotation results were not very good, and most people ended up not using it. The 3 new matching modes came about as a result of a conversation with my colleague Matt Corkum, where we quite literally brainstormed our way into it. Other improvements came about as a result of improvements in the infrastructure, such as the replacement of Memory Postings format in Solr (and SolrTextTagger) with the new FST Postings format, which effectively freed SoDA dictionary sizes from JVM size limitations.

Few other changes that are important to mention are a much cleaner, consistent (and unfortunately non backwards compatible) JSON interface. You can interact with the service using your own JSON over HTTP client, but we also expose a programmable API in Python and Scala, with clients provided for both. Finally, because the 2018 me did not care too much for the code written by 2016 me, as well as because of the changes described above, the code ended up being pretty heavily rewritten, with the result that it is cleaner and hopefully more maintainable. A full list of changes can be found in the Change log for v2.0.

Today's post is however, not about the new features listed above, but some new functionality I recently added to the project. Before I describe this new functionality, however, let me provide a little background to provide some justification for why I think it might be useful.

Like many companies, we have moved our data center to the cloud, specifically Amazon Web Services (AWS). So we have a SoDA server running 24x7 in our AWS cloud listening on annotation requests. Actual API usage is not very heavy, since annotating text is not a frequent activity to begin with, and SoDA is one of at least five available engines (to my knowledge), some of which are domain specific. However, we are incurring AWS charges even while the server sits idle. Ideally, we would like to spend our AWS dollars more efficiently.

On the other hand, for large annotation jobs (which come up occasionally), a single server can be too limiting and a better option would be a cluster of multiple servers behind a load balancer. Unfortunately, setting this up is a tedious and manual process and more often than not, these jobs just decide to do without the annotations available through SoDA.

The first thing I thought of was to keep the (single) SoDA server turned off while not in use and only start it on demand. The problem is that I would have to go in and manually start the services (Solr and Jetty/Tomcat for SoDA) each time. This can get tedious very quickly as you can imagine. One idea that occurred to me was to leverage the Unix startup/shutdown capabilities (the /etc/rc.d stuff for all you Unix old timers) to do this. Reading further, I found that the current way to do this was to use the systemd daemon, so I used that to build an "application service" which started and stopped my two services. I describe the steps in the Automatic Startup and Shutdown section of my SoDA v2.x installation guide.

Once I was able to do this, the next step was to "freeze" this instance as an Amazon Machine Image (AMI). This has a number of advantages, chief among them being the ability to spin up clusters of multiple instances of the AMI as shown in the right hand part of the figure below. In addition, it provides an additional "last known checkpoint" when it comes to building up dictionaries and doing software updates, as shown in the left hand part of the figure.


SoDA v2.x now comes with two Python scripts, master_instance.py and cluster_instance.py, both callable from the command line, that spin up the configurations on the left and right of the AMI in the figure above, respectively. Details about how to call them are in the AMI Maintenance (AWS) and Spinning up Read-only Clusters (AWS) sections of the SoDA v2.x installation guide.

Both scripts use the Boto3 library, the AWS SDK for Python3 to communicate with the AWS EC2 subsystem to start and stop the clusters. The master_instance.py script also has functionality to save and load the current state from a new AMI, thus allowing the AMI to evolve as software and dictionary changes are made.

Taken together, these scripts will now allow a annotator to spin up a cluster of a specified number of SoDA instances behind a load balancer without having to ask someone to do it for them, as well as allow maintainers to manage the SoDA instance (and AMI).

However, it still does not fully solve our original problem of having to run a SoDA instance 24x7, although it does go a long way towards making a solution possible. The most common use case for us is to access SoDA from a Spark Databricks notebook environment, so to support that, we would still need a system that is accessible to a specific host and port from within the Databricks network.

One solution which we have discussed internally, but for which I did not have the necessary knowledge (still don't, but am in the process of acquiring thanks to the AWS Developer: Building on AWS course on edX) is to have an Amazon API Gateway call that triggers an Amazon Lambda that starts a small cluster that listens on the host/port specified in the Databricks notebook. That would finally allow us to shut down our SoDA server, and give potential SoDA users on Databricks the capability to spin up a cluster of the designated size as needed, via a single API call.


Saturday, May 10, 2014

Preprocessing data with Scalding and Amazon EMR


Lately, I have been trying to do some data analysis against (anonymized) Medicare/Mediclaim data. The data contains member information (gender, race, data of birth, chronic conditions, etc) for 6.6M patients, and inpatient and outpatient claims (1.3M and 15.8M respectively) for the period 2008-2010. I started out using Pandas to do some basic analysis - nothing fancy, basically finding distribution of various chronic conditions by race and sex (I figured age wouldn't be as interesting, since the age range is quite narrow in this case), correlations between different chronic conditions, etc. You can see the IPython notebook containing this analysis here.

While analyzing the inpatient claims data, I wondered about the possiblity of building a model to predict the probability of chronic disease given the claim codes for a patient. Pandas over IPython was okay for doing analysis with a subset (single file) of data, but got a bit irritating with the full dataset because of frequent hangs and subsequent IPython server restarts. So I began to consider using Hadoop, preferably over Amazon EMR - and Scalding was similar enough to Pandas to make this the obvious choice (obvious for me, at least - I prefer Scalding over Pig, and I haven't used (S)Crunch or Scoobi).

The last time I used Scalding was when I wrote the Scalding for the Impatient series (a Scalding version of Paco Nathan's Cascading for the Impatient series) as a way to learn Scala. Since then I have used Cascading (Scalding's clunkier but richer Java predecessor), mainly because I couldn't run Scalding in anything other than local mode. When I looked this time I found this project template from the kind folks at Snowplow Analytics to run Scalding code on Amazon EMR, so that was no longer a problem.

This post describes a mixture of Python and Scala/Scalding code that I hooked up to convert the raw Benefits and Inpatient Claims data from the Medicare/Medicaid dataset into data for an X matrix and multiple y vectors, each y corresponding to a single chronic condition. Scalding purists would probably find this somewhat inelegant and prefer a complete Scalding end-to-end solution, but my Scalding-fu extends only so far - hopefully it will improve with practice.

Project Setup


The Snowplow example project is based on custom Scala code in the project/ directory - I'm used to simpler projects, with a build.sbt in the root directory. I tried this route for a bit but soon gave up because of the complexity involved. The example project was also built for Scala 2.9, and I use 2.10 (the Eclipse based Scala IDE is tied to a specific Scala version). The change also prompted changes to other versions hardcoded in the build files, as well as some minor code changes, all of which are described below. Alternatively, you can just grab the contents of my project directory on GitHub.

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Source: project/build.properties
updated sbt.version to 0.13.0

# project/plugins.sbt
* updated sbt-assembly from 0.8.5 to 0.10.1
* added sbteclipse-plugin 2.4.0

# project/ExampleScaldingProjectBuild.scala
* renamed this file and contained class to ProjectBuild.scala

# project/Dependencies.scala
* renamed ScalaToolsSnapshots alias to "Scalatools snapshots at Sonatype"
* updated specs2 to 1.13 per comment (not used in my case)

# project/BuildSettings.scala
* updated basicSettings
* fixed up compile failure caused by change in sbt-assembly API

The project also provides a JobRunner object that is used to call the selected Job class from Hadoop. After testing that the WordCount example still worked on Amazon EMR with my changes, I moved the JobRunner.scala file to my own job package and removed the package for the Word Count job.

Data Preparation


The Benefit summary and the inpatient claims data consist of 58 and 19 files respectively. I had initially thought that Scalding would provide some Source abstraction that could read off a directory, but I couldn't find one in the examples. So I wrote this Python snippet to concatenate them into 2 large files.

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
# Source: src/main/python/catfiles.py
import os
import sys

def usage():
  print "Usage: %s indir outfile skipheader" % (sys.argv[0])
  print "where:"
  print "indir - input directory of files to concat"
  print "outfile - outfile file to concat to"
  print "skipheader - true if header to be skipped else false"
  sys.exit(-1)

def main():
  if len(sys.argv) != 4 \
     or not os.path.isdir(sys.argv[1]) \
     or sys.argv[3] not in ["true", "false"]:
    usage()
  fout = open(sys.argv[2], 'wb')
  for fn in os.listdir(sys.argv[1]):
    print "Now processing: %s" % (fn)
    fin = open(os.path.join(sys.argv[1], fn), 'rb')
    should_skip_line = sys.argv[3] == "true"
    for line in fin:
      if should_skip_line: 
        should_skip_line = False
        continue
      fout.write(line)
    fin.close()
  fout.close()

if __name__ == "__main__":
  main()

Data Preprocessing Phase I


The first phase consists in creating three views of the claims data. The input data consists of 45 odd columns for different medical codes per claim record. The code data is sparse, ie, each claim would have only a few of these columns filled out. This phase reads the claims data and normalizes it into (member_id, code_type:code_value, number_of_claims) triples. From this normalized data, we also extract the unique code_type:code_value pairs (hereafter referred to as code_id) and the unique member_ids. Scalding code for that is shown below:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// Source: src/main/scala/com/mycompany/cmspp/PreprocJob1.scala
package com.mycompany.cmspp

import scala.io.Source

import com.twitter.scalding.Args
import com.twitter.scalding.Csv
import com.twitter.scalding.Job
import com.twitter.scalding.RichPipe
import com.twitter.scalding.TextLine

class PreprocJob1(args: Args) extends Job(args) {

  def normalizeClaims(line: String): List[(String,String)] = {
    val inputColnames = Schemas.InpatientClaims.map(
      sym => sym.name)
    val outputColnames = Schemas.Codes.map(sym => sym.name)
    val ocolset = outputColnames.toSet
    val colvals = line.split(",")
    val memberId = colvals.head
    inputColnames.zip(colvals)
      .filter(nv => ocolset.contains(nv._1))
      .filter(nv => (! nv._2.isEmpty()))
      .map(nv => nv._1.split("_").head + ":" + nv._2)
      .map(code => (memberId, code))
  }
  
  val claims = TextLine(args("claims"))
    .flatMap(('line) -> ('DESYNPUF_ID, 'CLAIM_CODE)) {
      line: String => normalizeClaims(line)
    }
    .project(('DESYNPUF_ID, 'CLAIM_CODE))
    .groupBy(('DESYNPUF_ID, 'CLAIM_CODE)) { 
      grp => grp.size('NUM_CLAIMS) 
    }

  val members = RichPipe(claims)
    .project('DESYNPUF_ID)
    .unique('DESYNPUF_ID)
    .write(Csv(args("members")))

  val codes = RichPipe(claims)
    .project('CLAIM_CODE)
    .unique('CLAIM_CODE)
    .write(Csv(args("codes")))
    
  claims.write(Csv(args("output")))
}

object PreprocJob1 {
  def main(args: Array[String]): Unit = {
    // input files
    val claims = "data/inpatient_claims.csv"
    // output files
    val members = "data/members_list.csv"
    val codes = "data/codes_list.csv"
    val output = "data/claim_triples.csv"
    (new PreprocJob1(Args(List(
        "--local", "", 
        "--claims", claims,
        "--members", members,
        "--codes", codes,
        "--output", output)))
    ).run
    Console.println("==== members_list ====")
    Source.fromFile(members).getLines().slice(0, 3)
      .foreach(Console.println(_))
    Console.println("==== codes_list ====")
    Source.fromFile(codes).getLines().slice(0, 3)
      .foreach(Console.println(_))
    Console.println("==== claim_triples ====")
    Source.fromFile(output).getLines().slice(0, 3)
      .foreach(Console.println(_))
  }
}

To run this on Amazon EMR, comment out the companion object that is used for local testing, then build the fat JAR with "sbt assembly", then upload the JAR in target/scala-2.10 and the data files to S3. To launch the job in EMR, use the following parameters:

1
2
3
4
5
6
7
8
Job JAR: s3://${BUCKET}/cmspp/scalding-meddata-preproc-0.1.0.jar
Job Arguments:
    com.mycompany.cmspp.PreprocJob1 \
    --hdfs \
    --claims s3://${BUCKET}/cmspp/claims/inpatient_claims.csv \
    --members s3://${BUCKET}/cmspp/members \
    --codes s3://${BUCKET}/cmspp/codes \
    --output s3://${BUCKET}/cmspp/triples

The first 3 lines of outputs for this phase (generated using a local run against a truncated dataset) are shown below.

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
==== members_list ====
0001448457F2ED81
000188A3402777A5
0001EB1229306825
==== codes_list ====
CLM:202
CLM:203
CLM:206
==== claim_triples ====
0001448457F2ED81,CLM:217,1
0001448457F2ED81,CLM:460,1
0001448457F2ED81,CLM:881,1

Assigning Numeric IDs


For the two unique lists of member_ids and code_ids, we assign a sequential value so we can convert the claim triples data into a sparse matrix. Since Scalding assumes a distributed system, assigning a unique serial number is a hard thing to do - there are ways to do it with the groupAll() method which forces using a single reducer, but I couldn't make it work. Of course, this is trivial to do in a non-distributed environment. The following Python code is run against the member_id list and code_id list to produce "dictionaries" of (member_id, int) and (code_id, int) pairs.

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
# Source: src/main/python/add_lineno.py
import os
import sys

def usage():
  print "Usage: %s infile outfile" % (sys.argv[0])
  print "where:"
  print "infile - input CSV file without line number"
  print "outfile - output CSV file with line number as first col"
  sys.exit(-1)

def main():
  if len(sys.argv) != 3 \
      or not os.path.isfile(sys.argv[1]):
    usage()
  fin = open(sys.argv[1], 'rb')
  fout = open(sys.argv[2], 'wb')
  lno = 0
  for line in fin:
    fout.write("%d,%s" % (lno, line))
    lno += 1
  fin.close()
  fout.close()

if __name__ == "__main__":
  main()

Data Preprocessing Phase II


This phase reads the dictionaries generated by the Python code above and the claims triples data to produce an X matrix and a set of y vectors (one for each chronic condition). The X matrix is L2-normalized and in sparse format. Here is the Scalding code to do this transformation.

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// Source: src/main/scala/com/mycompany/cmspp/PreprocJob2.scala
package com.mycompany.cmspp

import scala.io.Source

import com.twitter.scalding.Args
import com.twitter.scalding.Csv
import com.twitter.scalding.Job
import com.twitter.scalding.mathematics.Matrix.pipeExtensions

class PreprocJob2(args: Args) extends Job(args) {

  val benefits = Csv(args("benefits"), 
    fields=Schemas.BenefitSummary)
  val claims = Csv(args("claims"), 
    fields=List('DESYNPUF_ID, 'CLAIM_CODE, 'NUM_CLAIMS))
  val memberDict = Csv(args("members"),
    fields=List('MEM_IDX, 'DESYNPUF_ID))
  val codesDict = Csv(args("codes"), 
    fields=List('COD_IDX, 'CLAIM_CODE))
  
  claims.joinWithSmaller('DESYNPUF_ID -> 'DESYNPUF_ID, memberDict)
    .joinWithSmaller('CLAIM_CODE -> 'CLAIM_CODE, codesDict)
    .toMatrix[Long,Long,Double]('MEM_IDX, 'COD_IDX, 'NUM_CLAIMS)
    .rowL2Normalize
    .pipe
    .mapTo(('row, 'col, 'val) -> ('row, 'colval)) { 
      row: (Long, Long, Double) => 
        (row._1, row._2.toString + ":" + row._3.toString)
    }
    .groupBy('row) { grp => grp.mkString('colval, ",") }
    .write(Csv(args("xmatrix")))
    
  benefits.project(Schemas.Diseases)
    .joinWithSmaller('DESYNPUF_ID -> 'DESYNPUF_ID, memberDict)
    .discard('DESYNPUF_ID)
    .project('MEM_IDX :: Schemas.Diseases.tail)
    .write(Csv(args("yvectors")))
}

object PreprocJob2 {
  def main(args: Array[String]): Unit = {
    // input files
    val benefits = "data/benefit_summary.csv"
    val triples = "data/claim_triples.csv"
    val memberDict = "data/members_dict.csv"
    val codeDict = "data/codes_dict.csv"
    // output files
    val xmatrix = "data/x_matrix.csv"
    val yvectors = "data/y_vectors.csv"
    (new PreprocJob2(Args(List(
      "--local", "",
      "--benefits", benefits,
      "--claims", triples,
      "--members", memberDict,
      "--codes", codeDict,
      "--xmatrix", xmatrix,
      "--yvectors", yvectors)))
    ).run
    Console.println("==== xmatrix.csv ====")
    Source.fromFile(xmatrix).getLines().slice(0, 3)
      .foreach(Console.println(_))
    Console.println("==== yvectors.csv ====")
    Source.fromFile(yvectors).getLines().slice(0, 3)
      .foreach(Console.println(_))
  }
}

The inputs to this phase are the original benefits file, the claims triple data generated in Phase I and the two dictionaries generated in the previous step. The fat JAR already contains the PreprocJob2, so we can just reuse the JAR from the previous step. Parameters to launch the job on Amazon EMR are shown below.

1
 2
 3
 4
 5
 6
 7
 8
 9
10
Job JAR: s3://${BUCKET}/cmspp/scalding-meddata-preproc-0.1.0.jar
Job Arguments:
    com.mycompany.cmspp.PreprocJob2 \
    --hdfs \
    --benefits s3://${BUCKET}/cmspp/benefits/benefit_summary.csv \
    --claims s3://${BUCKET}/cmspp/triples/ \
    --members s3://${BUCKET}/cmspp/members_dict/members_dict.csv \ 
    --codes s3://${BUCKET}/cmspp/codes_dict/codes_dict.csv \
    --xmatrix s3://${BUCKET}/cmspp/xmatrix \
    --yvectors s3://${BUCKET}/cmspp/yvectors

Here is what the output data looks like. As you can see, this data can now be used to construct sparse or dense input matrices for training a classification or regression algorithm. Once again, to the question of elegance, I realize that the columns could have been sorted, and the values in the yvectors should have been {0,1} not {1,2}. But these are easy to handle in downstream programs.

1
2
3
4
5
6
7
8
==== xmatrix.csv ====
0,"69:0.15617376188860607,65:0.15617376188860607,..."
1,"70:0.16222142113076254,68:0.16222142113076254,..."
2,"52:0.3333333333333333,43:0.3333333333333333,..."
==== yvectors.csv ====
0,1,1,1,2,1,1,1,1,2,2,2
1,1,1,1,1,1,1,1,1,2,2,2
2,2,1,2,1,2,2,2,1,2,1,2

Thats all I have for today. I have wanted for a while to be able to build Scalding jobs that could be run on Amazon EMR (or a Hadoop cluster other than on my laptop), so this is quite big for me. I plan on using this data as input to some classification program - I will write about that if its interesting enough to share. All the code described here can be found in GitHub page for this project.

Saturday, June 01, 2013

MapReduce with Python and mrjob on Amazon EMR


I've been doing the Introduction to Data Science course on Coursera, and one of the assignments involved writing and running some Pig scripts on Amazon Elastic Map Reduce (EMR). I've used EMR in the past, but have avoided it ever since I got burned pretty badly for leaving it on. Being required to use it was a good thing, since I got over the inertia and also saw how much nicer the user interface had become since I last saw it.

I was doing another (this time Python based) project for the same class, and figured it would be educational to figure out how to run Python code on EMR. From a quick search on the Internet, mrjob from Yelp appeared to be the one to use on EMR, so I wrote my code using mrjob.

The code reads an input file of sentences, and builds up trigram, bigram and unigram counts of the words in the sentences. It also normalizes the text, lowercasing, replacing numbers and stopwords with placeholder tokens, and Porter stemming the remaining words. Heres the code, as you can see, its fairly straightforward:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
from __future__ import division
from mrjob.job import MRJob
import nltk
import string

class NGramCountingJob(MRJob):

  def mapper_init(self):
#    self.stopwords = nltk.corpus.stopwords.words("english")
    self.stopwords = set(['i', 'me', 'my', 'myself', 'we',
      'our', 'ours', 'ourselves', 'you', 'your', 'yours',
      'yourself', 'yourselves', 'he', 'him', 'his', 'himself',
      'she', 'her', 'hers', 'herself', 'it', 'its', 'itself',
      'they', 'them', 'their', 'theirs', 'themselves', 'what',
      'which', 'who', 'whom', 'this', 'that', 'these', 'those',
      'am', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
      'have', 'has', 'had', 'having', 'do', 'does', 'did',
      'doing', 'a', 'an', 'the', 'and', 'but', 'if', 'or',
      'because', 'as', 'until', 'while', 'of', 'at', 'by',
      'for', 'with', 'about', 'against', 'between', 'into',
      'through', 'during', 'before', 'after', 'above', 'below',
      'to', 'from', 'up', 'down', 'in', 'out', 'on', 'off',
      'over', 'under', 'again', 'further', 'then', 'once',
      'here', 'there', 'when', 'where', 'why', 'how', 'all',
      'any', 'both', 'each', 'few', 'more', 'most', 'other',
      'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same',
      'so', 'than', 'too', 'very', 's', 't', 'can', 'will',
      'just', 'don', 'should', 'now'])
    self.porter = nltk.PorterStemmer()

  def mapper(self, key, value):

    def normalize_numeric(x):
      xc = x.translate(string.maketrans("", ""), string.punctuation)
      return "_NNN_" if xc.isdigit() else x

    def normalize_stopword(x):
      return "_SSS_" if str(x) in self.stopwords else x

    cols = value.split("|")
    words = nltk.word_tokenize(cols[1])
    # normalize number and stopwords and stem remaining words
    words = [word.lower() for word in words]
    words = [normalize_numeric(word) for word in words]
    words = [normalize_stopword(word) for word in words]
    words = [self.porter.stem(word) for word in words]
    trigrams = nltk.trigrams(words)
    for trigram in trigrams:
      yield (trigram, 1)
      bigram = trigram[1:]
      yield (bigram, 1)
      unigram = bigram[1:]
      yield (unigram, 1)

  def reducer(self, key, values):
    yield (key, sum([value for value in values]))

if __name__ == "__main__":
  NGramCountingJob.run()

The class must extend MRJob and call its run() method when invoked from the shell. The MRJob class implements a sequence of methods that will be called (which can be overriden) - so we just override the appropriate methods. The mrjob framework runs over Hadoop Streaming, but offers many convenience features.

I first tried using the EMR console to create a job flow with mrjob, but the closest I found was "Streaming Jobs". Streaming jobs require the mapper and reducer scripts and the input files to reside on Amazon's S3 storage. Output is also written to S3. However, I was not able to make this setup work with the mrjob code above.

Reading some more, it turns out that mrjob jobs can be started from your local shell with the "-r emr" switch, and it will copy your input and scripts to S3, create a job flow, run your job, write output to S3, and then copy the output back to STDOUT of your local shell, where you can capture it. The first thing that is needed is an .mrjob.conf file. Mine looks like this (with the secret bits appropriately sanitized).

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Source: $HOME/.mrjob.conf
runners:
  emr:
    aws_access_key_id: 53CR3T53CR3T53CR3T53
    aws_region: us-west-1
    aws_secret_access_key: SuperSecretAccessKeyIfITellYouGottaKillU
    ec2_key_pair: EMR
    ec2_key_pair_file: /path/to/pem/file.pem
    bootstrap_cmds:
    - sudo easy_install http://nltk.googlecode.com/files/nltk-2.0b5-py2.6.egg
    ec2_instance_type: m1.small
    num_ec2_core_instances: 4
    cmdenv:
      TZ: America/Los_Angeles
  local:
    base_tmp_dir: /tmp/$USER

The configuration will start up a EC2 m1.small master node with 4 slave nodes of the same type. The bootstrap_cmds installs NLTK on all the worker nodes, since my code is using it and because it doesn't come standard with Python installs. I also had a call to nltk.corpus to read English stopwords, but I just changed the code to declare the list explicitly since I didn't want to install the full corpus.

You can run the code locally (for testing, generally on a subset of the data) as follows.

1
sujit@cyclone:parw$ python ngram_counting_job.py input.txt > output.txt

Or you can run on EMR by adding a "-r emr" switch, or running on your own Hadoop cluster by adding a "-r hadoop" switch to your command. The EMR version is shown below.

1
sujit@cyclone:parw$ python ngram_counting_job.py input.txt -r emr > output.txt

Of course, you can monitor your job from the EMR console as it is running. This is all I've done with mrjob so far, but I hope to do much more with it.

Friday, July 10, 2009

Running a Hadoop Job on Amazon EC2

Sometime early last year, a colleague went to the WWW2008 conference at Beijing. One of the ideas he brought back was that of identifying common phrases in use in your vertical by extracting them from the documents in your corpus - the paper it came out of was not even one of the major ones, but it stuck to me, because of its simplicity.

I didn't know anything about Hadoop at the time, so while I had an implementation figured out shortly after the talk, I did not write any code, since I did not have a way to run it on a sufficiently large volume of text. Lately, however, I've been looking at Hadoop again, with a view to running jobs on Amazon's Elastic Compute (EC2) service, so I figured that it may be a good thing to try out.

The way I planned to do it was to generate 2 to 5 word grams from the document, then aggregating them. As an example, the text:

1
First, she tried to look down and make out what she was coming to,...
is decomposed to the following subsequences, then passed into a Hadoop MapReduce job to find how many times each phrase occurred. Downstream code will presumably treat the highest occurring phrases as "common" somehow.

1
2
3
4
5
6
7
8
(first she)
(first she tried)
(first she tried to)
(first she tried to look)
(she tried)
(she tried to)
(she tried to look)
... etc.

The books I used as my "corpus" for this test are Alice in Wonderland, Moby Dick and The Adventures of Sherlock Holmes, all from Project Gutenberg's collection of e-books.

Amazon EC2 Setup

Setting up to work with Amazon's EC2 service is easy if you know how. There are lots of Internet resources, including Amazon's own EC2 documentation pages, that provide information about this. Chuck Lam's Hadoop in Action (Early Access) book has an entire chapter devoted to this, and I basically followed it step by step, and was successful. In a nutshell, here is what I needed to do.

  1. From Amazon's site, create and download the private key file (pk-*) and certificate (cert-*) and copy it to your ~/.ec2 directory.
  2. Download and install Hadoop (if not installed already).
  3. Download and install Amazon's EC2 API Tools.
  4. From Amazon's site, get your account number, the AWS Access key, and the AWS Secret Access Key, and put it in the appropriate places in your $HADOOP_HOME/src/contrib/ec2/hadoop-ec2-env.sh file.
  5. Figure out what instance type you want (I chose m1.medium), and update the hadoop-ec2-env.sh file.
  6. Add this information into your .bash_profile. The snippet from my .bash_profile is shown below. This puts the ec2 api tools and the hadoop-ec2 tools in your PATH, and also provides the tools with information about your private key and certificate.
  7. Source your .bash_profile.
  8. Generate your keypair (ec2-add-keypair gsg-keypair) and store the private part of the generated RSA key to ~/.ec2/id_rsa-gsg-keypair with permissions 600. The tool will put the public part of this keypair in Amazon's repository so you can have passphraseless ssh connectivity.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Snippet from my .bash_profile file (the EC2_PRIVATE_KEY and
# EC2_CERT values are set to dummy values.
...
# Hadoop
export HADOOP_HOME=/opt/hadoop-0.18.1

# EC2 Access
export EC2_HOME=/opt/ec2-api-tools-1.3-36506
export PATH=$PATH:$EC2_HOME/bin:$HADOOP_HOME/src/contrib/ec2/bin
export EC2_PRIVATE_KEY=$HOME/.ec2/pk-ABCD1234EFGH5678.pem
export EC2_CERT=$HOME/.ec2/cert-ABCD1234EFGH5678.pem
...

The code

The book text is first broken up into sentences, and then put together in one large file, one sentence per line. It is run offline, as a sort of data preparation step. Here is the code - there is no Hadoop code here, all it does is to read each of the files downloaded off the Gutenberg site, tokenize the content into sentences, and write them out to the output file.

 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Source: src/main/java/net/sf/jtmt/concurrent/hadoop/phraseextractor/OfflineSentenceGenerator.java
package net.sf.jtmt.concurrent.hadoop.phraseextractor;

import java.io.File;
import java.io.FileWriter;
import java.io.PrintWriter;
import java.text.BreakIterator;

import org.apache.commons.io.FileUtils;

/**
 * Preprocesses the Gutenberg books into sentences, one sentence
 * per line.
 */
public class OfflineSentenceWriter {

  private String inputDirectoryPath;
  private String outputFilePath;
  
  public void setInputDirectory(String inputDirectoryPath) {
    this.inputDirectoryPath = inputDirectoryPath;
  }
  
  public void setOutputFile(String outputFilePath) {
    this.outputFilePath = outputFilePath;
  }
  
  public void convertToSentencePerLineFormat() throws Exception {
    File[] inputs = new File(inputDirectoryPath).listFiles();
    PrintWriter output = new PrintWriter(
      new FileWriter(outputFilePath), true);
    for (File input : inputs) {
      BreakIterator sentenceIterator = BreakIterator.getSentenceInstance();
      String text = FileUtils.readFileToString(input, "UTF-8");
      text = text.replaceAll("\n", " ");
      sentenceIterator.setText(text);
      int current = 0;
      for (;;) {
        int end = sentenceIterator.next();
        if (end == BreakIterator.DONE) {
          break;
        }
        String sentence = text.substring(current, end);
        output.println(sentence);
        current = end;
      }
    }
    output.flush();
    output.close();
  }
}

The code to convert a sentence into a series of word grams is done using the WordNGramGenerator.java shown below. It takes a input string (a sentence in our case), and the minimum and maximum size of the word grams to be produced. I find it helpful to pull out the complex parts into their own classes and just use it inside the MapReduce job, rather than building it into the MapReduce code directly, because that way its easier to test.

 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// Source: src/main/java/net/sf/jtmt/concurrent/hadoop/phraseextractor/WordNGramGenerator.java
package net.sf.jtmt.concurrent.hadoop.phraseextractor;

import java.io.IOException;
import java.text.BreakIterator;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;

import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Given a sentence, generates the specified word N-grams from it and
 * returns it as a List of String.
 */
public class WordNGramGenerator {

  private final Log log = LogFactory.getLog(getClass());
  
  public List<String> generate(String input, int minGram, int maxGram) 
      throws IOException {
    List<String> wordgrams = new ArrayList<String>();
    List<String> tokens = new LinkedList<String>();
    BreakIterator wordBreakIterator = 
      BreakIterator.getWordInstance(Locale.getDefault());
    wordBreakIterator.setText(input);
    int current = 0;
    int gindex = 0;
    for (;;) {
      int end = wordBreakIterator.next();
      if (end == BreakIterator.DONE) {
        // take care of the remaining word grams
        while (tokens.size() >= minGram) {
          wordgrams.add(StringUtils.join(tokens.iterator(), " "));
          tokens.remove(0);
        }
        break;
      }
      String nextWord = input.substring(current, end);
      current = end;
      if ((StringUtils.isBlank(nextWord)) ||
          (nextWord.length() == 1 && nextWord.matches("\\p{Punct}"))) {
        continue;
      }
      gindex++;
      tokens.add(StringUtils.lowerCase(nextWord));
      if (gindex == maxGram) {
        for (int i = minGram; i <= maxGram; i++) {
          wordgrams.add(StringUtils.join(
            tokens.subList(0, i).iterator(), " "));
        }
        gindex--;
        tokens.remove(0);
      }
    }
    return wordgrams;
  }
}

And finally, the MapReduce job to do the phrase extraction and aggregation. The Map class reads a sentence at a time, then calls the WordNGramGenerator to produce the word n-grams, and writes them out. On the Reduce side, Hadoop already has a convenience Reducer (the LongSumReducer) for what I am doing, so I use that.

 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Source: src/main/java/net/sf/jtmt/concurrent/hadoop/phraseextractor/PhraseExtractor.java
package net.sf.jtmt.concurrent.hadoop.phraseextractor;

import java.io.IOException;
import java.util.List;

import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.io.Writable;
import org.apache.hadoop.io.WritableComparable;
import org.apache.hadoop.mapred.FileInputFormat;
import org.apache.hadoop.mapred.FileOutputFormat;
import org.apache.hadoop.mapred.JobClient;
import org.apache.hadoop.mapred.JobConf;
import org.apache.hadoop.mapred.MapReduceBase;
import org.apache.hadoop.mapred.Mapper;
import org.apache.hadoop.mapred.OutputCollector;
import org.apache.hadoop.mapred.Reporter;
import org.apache.hadoop.mapred.lib.LongSumReducer;

/**
 * Breaks up input text into sentences, then generates 2-5 grams of
 * the input text.
 */
public class PhraseExtractor {

  private static class MapClass extends MapReduceBase 
      implements Mapper<WritableComparable<Text>,Writable,
                 WritableComparable<Text>,Writable> {

    private static final LongWritable ONE = new LongWritable(1);
    
    public void map(WritableComparable<Text> key, Writable value,
        OutputCollector<WritableComparable<Text>,Writable> output,
        Reporter reporter) throws IOException {
      String sentence = ((Text) value).toString();
      WordNGramGenerator ngramGenerator = new WordNGramGenerator();
      List<String> grams = ngramGenerator.generate(sentence, 2, 5);
      for (String gram : grams) {
        output.collect(new Text(gram), ONE);
      }
    }
  }

  public static void main(String[] argv) throws IOException {
    if (argv.length != 2) {
      System.err.println("Usage: calc input_path output_path");
    }
    JobConf conf = new JobConf(PhraseExtractor.class);
    
    FileInputFormat.addInputPath(conf, new Path(argv[0]));
    FileOutputFormat.setOutputPath(conf, new Path(argv[1]));
    
    conf.setOutputKeyClass(Text.class);
    conf.setOutputValueClass(LongWritable.class);
    
    conf.setMapperClass(MapClass.class);
    conf.setCombinerClass(LongSumReducer.class);
    conf.setReducerClass(LongSumReducer.class);
    conf.setNumReduceTasks(2);
    
    JobClient.runJob(conf);
  }
}

The above code needs to be packaged appropriately into a JAR file. Here is the snippet of Ant code that does this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
  <target name="build-hadoop-phrase-extractor" 
      depends="_init" description="Build Phrase Extractor job on Hadoop">
    <!-- create new directory target/lib and copy required runtime
         dependencies for the hadoop job into it -->
    <delete dir="${maven.build.directory}/jars"/>
    <mkdir dir="${maven.build.directory}/jars/lib"/>
    <copy todir="${maven.build.directory}/jars/lib" flatten="true">
      <fileset dir="${maven.repo.local}">
        <include name="commons-lang/commons-lang/2.1/commons-lang-2.1.jar"/>
        <include name="commons-io/commons-io/1.2/commons-io-1.2.jar"/>
      </fileset>
    </copy>
    <!-- create jar file for phrase-extractor -->
    <jar jarfile="${maven.build.directory}/phrase-extractor.jar">
      <fileset dir="${maven.build.directory}/classes"/>
      <fileset dir="${maven.build.directory}/jars"/>
      <manifest>
        <attribute name="Main-Class"
          value="net/sf/jtmt/concurrent/hadoop/phraseextractor/PhraseExtractor"/>
      </manifest>
    </jar>
  </target>

Running the Code in EC2

Caution: You are now entering the paid area!. If you are playing along, at this point Amazon is going to charge your credit card for machine time spent. First we launch our EC2 cluster with the following command:

1
2
3
4
5
6
7
8
sujit@sirocco:~/src/jtmt$ hadoop-ec2 launch-cluster sujit 4
Testing for existing master in group: sujit
...
Adding sujit node(s) to cluster group sujit with AMI ami-fe37d397
i-21ebda48
i-23ebda4a
i-25ebda4c
i-27ebda4e

Next we login to our master node. We will run our jobs from the command line on the EC2 master node.

1
2
3
sujit@sirocco:~/src/jtmt$ hadoop-ec2 login sujit
...
[root@domU ~]# 

We then copy over our jar file and input file to the EC2 master node. Our input file is the output of OfflineSentenceWriter, and contains one sentence per line. If you source the hadoop-ec2-env.sh file, you will get access to the environment variable SSH_OPTS, which is convenient. So...

1
2
3
4
5
sujit@sirocco:~/src/jtmt$ . $HADOOP_HOME/src/contrib/ec2/bin/hadoop-ec2-env.sh 
sujit@sirocco:~/src/jtmt$ scp $SSH_OPTS target/phrase-extractor.jar \
  root@ec2-123-456-789-01.compute-1.amazonaws.com:/root
sujit@sirocco:~/src/jtmt$ scp $SSH_OPTS books.txt \
  root@ec2-123-456-789-01.compute-1.amazonaws.com:/root

On the EC2 master node, we create a HDFS directory and put the input file into it. You can verify that the file got written using bin/hadoop dfs -lsr /.

1
2
3
[root@domU ~]# cd /usr/local/hadoop-0.18.1/
[root@domU hadoop-0.18.1]# bin/hadoop fs -mkdir /usr/root/inputs
[root@domU hadoop-0.18.1]# bin/hadoop dfs -put ~/books.txt /usr/root/inputs

When I first ran the job, I got back compressed files as the output of my reduce step. Because I didn't want to do the extra step that is mentioned here, I changed the configuration (in conf/hadoop-site.xml) to output without compression, and reran my job.

1
2
3
4
<property>
  <name>mapred.output.compress</name>
  <value>false</value> <!-- was "true" -->
</property>

Here is the command to run the Hadoop job.

1
2
3
[root@domU hadoop-0.18.1]# bin/hadoop jar /root/phrase-extractor.jar \
  hdfs://domU.compute-1.internal:50001/usr/root/inputs/books.txt \
  hdfs://domU.compute-1.internal:50001/usr/root/outputs

While the code runs, you can also monitor the job through a web interface on port 50030 on the master node. Here are some screenshots.

The job dropped two part-nnnnn files in HDFS in the output subdirectory. I first copied these back to the regular file system on the master node.

1
2
[root@domU ~]# bin/hadoop dfs -get /usr/root/outputs/part-00000 ~/part-00000
...

then back to my local box using scp.

1
sujit@sirocco:~/src/jtmt$ scp $SSH_OPTS root@ec2-123-456-789-01.compute-1.amazonaws.com:/root/part-* .

Once done, the cluster can be terminated with this command. At that point, you will exit the Amazon EC2 paid area.

1
2
3
4
5
6
7
8
sujit@sirocco:~/src/jtmt$ hadoop-ec2 terminate-cluster sujit
...
Terminate all instances? [yes or no]: yes
INSTANCE i-d7e8d9be running shutting-down
INSTANCE i-21ebda48 running shutting-down
INSTANCE i-23ebda4a running shutting-down
INSTANCE i-25ebda4c running shutting-down
INSTANCE i-27ebda4e running shutting-down

The part-nnnn files are not sorted by aggregated count, and contain more information than I need. I guess the correct approach is to run another MapReduce to filter and sort the data, but now that the files are not too large, you can just use some Unix command line tools to do this:

1
2
3
4
sujit@sirocco:~/src/jtmt$ cat part-00000 part-00001 | \
awk -F"\t" '{if ($2 != 1) print $0}' | \
sed -e 's/\t/:/' | \
sort -n -r -t ':' -k2 - > sorted

Which returns the expected results (sort of). I realize now that perhaps doing 2-grams to find phrases was a bit ambitous and I should have considered 3 to 5 grams only. If I look only at 3-grams, I find quite a few good phrases such as "as much as", etc.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
of the:2201
in the:1426
to the:892
it was:587
and the:571
it is:562
at the:532
to be:482
from the:478
on the:452
...

The code runs pretty quickly on my local machine and runs even quicker on the EC2 cluster, so I probably did not need to run this on EC2, and in that sense is a waste of money. However, my main aim with this exercise was to set myself up on Amazon EC2 for future processing, so in that sense the expense was justified. I hope you found it useful.

Update 2009-07-26: I fixed the bug in the n-gram generation that Yuval pointed out in the comments below, and reran the job with 3-5 grams this time. I get slightly better results, as shown below:

1
2
3
4
5
6
7
8
one of the:121
the sperm whale:83
out of the:82
it was a:79
it is a:77
the white whale:73
of the whale:68
there was a:64

These look a bit more like common phrases that can occur in the body of text selected. The whale references are from Moby Dick, which probably outweighs the other two books in volume.