So far, I had been using Hadoop 0.18 for my (admittedly rather minimal) Map-Reduce coding. Recently, when trying to find some random feature which I ended up never using, I discovered that it was only available in 0.19 upwards, and when I went to download, Hadoop was already up to 0.20, so I downloaded that instead. Now, Hadoop 0.20 came with some pretty massive API changes, which I did not know about when I downloaded it, and soon the code in my Eclipse IDE was awash in yellow squigglies and strikethroughs.
The yellow squigglies and strikethroughs are, of course, Eclipse telling me that I am looking at/using deprecated method calls. Hadoop 0.20 will support the old API as well, but it feels kind of wrong to write new code against a deprecated API knowing that it will disappear within a few releases. There is not much available by way of example, so I figured that if I could write my next Map-Reduce job using the new API, it would help me figure it out, so I did.
Algorithm
The job was to create a term-document matrix of the terms in about 150+ of my blog posts. I want to use the matrix for some experiments which I will describe later. The matrix is built in three steps as described in the table below:
| Job-# | Phase | Input | Output |
| 1 | Map | <LineNum,Post> | List(<(docId,word),count>) |
| Reduce | <(docId,word),List(count)> | List(<(docId,word),count>) | |
| 2 | Map | <(docId,word),count> | List(<word, count>) |
| Reduce (LongSumReducer) | <word,List(count)> | List(<word,count>) | |
| 3 | Map | <(docId,word),count> | List(<docId,(word,count)>) |
| Reduce | <docId,List((word,count))> | List(<docId,CSV(count)>) |
The input to this process is a sequence file of blogs. As always, the key is the line number (a LongWritable). In our case, the value is a MapWritable containing body, keyword and title. The output of the first phase is a list of (docId:word) => occurrence.
The second phase takes the output of the first phase, and just counts the word occurrences across the documents. This is used later to map words to their respective positions in the matrix.
The third stage uses the outputs of the first and second phases. The output of the first phase is converted to a List of docId => dense comma-separated list of positional occurrences. This can now be used as input to a matrix building component.
Code
Here is the code. The main method contains the driver code, and there are three Mapper and three Reducer classes built as public static inner classes. The new API seems to be cleaner than the old one, at least to me, but then I don't have a huge amount of unlearning to do, which may be the case for people who use it more heavily.
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 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 | // Source: src/main/java/net/sf/jtmt/indexers/hadoop/TermDocumentMatrixGenerator.java
package net.sf.jtmt.indexers.hadoop;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import net.sf.jtmt.tokenizers.lucene.NumericTokenFilter;
import org.apache.commons.lang.StringUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FSDataInputStream;
import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.MapWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.SequenceFileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.mapreduce.lib.output.SequenceFileOutputFormat;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;
import org.apache.hadoop.util.GenericOptionsParser;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.LowerCaseFilter;
import org.apache.lucene.analysis.PorterStemFilter;
import org.apache.lucene.analysis.StopFilter;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardFilter;
import org.apache.lucene.analysis.standard.StandardTokenizer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.Field.Index;
import org.apache.lucene.document.Field.Store;
import org.apache.lucene.document.Field.TermVector;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.TermFreqVector;
import org.apache.lucene.index.IndexWriter.MaxFieldLength;
import org.apache.lucene.store.RAMDirectory;
/**
* A 3-stage Hadoop job that reads a sequence file of blog articles,
* and produces a term document vector of the article collection as
* its output. The phases are as follows:
* (1) Analyze each article using a custom Lucene analyzer and use
* Lucene's Term API to write out (docId:word) => occurrence.
* (2) Read output of previous step and write out word => occurrence.
* (3) Read output of step 2 to create a word to position map. Read
* output of step 1 to map docId => (word:occurrence) in the map
* phase, and docId => position,... in the reduce phase.
* @author Sujit Pal
* @version $Revision: 26 $
*/
public class TermDocumentMatrixGenerator {
/** Minimum occurrence for a given word in a document to be counted */
private static final int MIN_OCCUR_THRESHOLD = 2;
/** Key for the stop word file */
private static final String STOPWORD_FILE_LOCATION_KEY = "stf.loc.key";
/** Key for the terms directory generated in phase 2 */
private static final String TERMS_DIR_KEY = "terms.dir.key";
/* =============== Phase 1 ============== */
/**
* Map phase converts an input document into a set of (docId:word)
* => occurrence pairs. Stop words are removed, words are stemmed,
* etc, using a custom analyzer. Words which occur below a threshold
* are removed from consideration.
*/
public static class Mapper1 extends
Mapper<Text,MapWritable,Text,LongWritable> {
private Analyzer analyzer;
protected void setup(Context context)
throws IOException, InterruptedException {
Configuration conf = context.getConfiguration();
FileSystem hdfs = FileSystem.get(conf);
FSDataInputStream fis = hdfs.open(
new Path(conf.get(STOPWORD_FILE_LOCATION_KEY)));
final Set<String> stopset = new HashSet<String>();
BufferedReader reader = new BufferedReader(new InputStreamReader(fis));
String line = null;
while ((line = reader.readLine()) != null) {
stopset.add(line);
}
reader.close();
fis.close();
analyzer = new Analyzer() {
public TokenStream tokenStream(String fieldName, Reader reader) {
return new PorterStemFilter(
new StopFilter(
new LowerCaseFilter(
new NumericTokenFilter(
new StandardFilter(
new StandardTokenizer(reader)))), stopset));
}
};
}
@Override
public void map(Text key, MapWritable value, Context context)
throws IOException, InterruptedException {
Text content = (Text) value.get(new Text("content"));
TermFreqVector contentTf = getTermFrequencyVector(content.toString());
String[] terms = contentTf.getTerms();
int[] frequencies = contentTf.getTermFrequencies();
for (int i = 0; i < terms.length; i++) {
Text outputKey = new Text(StringUtils.join(
new String[] {key.toString(), terms[i]}, ":"));
context.write(outputKey, new LongWritable(frequencies[i]));
}
}
private TermFreqVector getTermFrequencyVector(String content)
throws IOException {
RAMDirectory ramdir = new RAMDirectory();
IndexWriter writer = new IndexWriter(ramdir, analyzer,
MaxFieldLength.UNLIMITED);
Document doc = new Document();
doc.add(new Field("text", content.toString(),
Store.YES, Index.ANALYZED, TermVector.YES));
writer.addDocument(doc);
writer.commit();
writer.close();
IndexReader reader = IndexReader.open(ramdir);
TermFreqVector termFreqVector = reader.getTermFreqVector(0, "text");
reader.close();
return termFreqVector;
}
}
/**
* Sums up the occurrences of (docId:word) occurrences and removes
* those which occur infrequently.
*/
public static class Reducer1 extends
Reducer<Text,LongWritable,Text,LongWritable> {
@Override
public void reduce(Text key, Iterable<LongWritable> values,
Context context) throws IOException, InterruptedException {
long sum = 0L;
for (LongWritable value : values) {
sum += value.get();
}
if (sum > MIN_OCCUR_THRESHOLD) {
context.write(key, new LongWritable(sum));
}
}
}
/* =============== Phase 2 ============== */
/**
* Extract the word from the key of the previous output, and emit
* them. This will be used to build a word to position map.
*/
public static class Mapper2 extends
Mapper<Text,LongWritable,Text,LongWritable> {
private static final LongWritable ONE = new LongWritable(1);
@Override
public void map(Text key, LongWritable value, Context context)
throws IOException, InterruptedException {
String word = StringUtils.split(key.toString(), ":")[1];
context.write(new Text(word), ONE);
}
}
/**
* Aggregates the word count.
*/
public static class Reducer2 extends
Reducer<Text,LongWritable,Text,LongWritable> {
@Override
public void reduce(Text key, Iterable<LongWritable> values,
Context context) throws IOException, InterruptedException {
long occurrences = 0;
for (LongWritable value : values) {
occurrences++;
}
context.write(key, new LongWritable(occurrences));
}
}
/* =============== Phase 3 ============== */
/**
* Reads the output of step 1 and emits docId => (word:occurrence)
* pairs.
*/
public static class Mapper3 extends Mapper<Text,LongWritable,Text,Text> {
@Override
public void map(Text key, LongWritable value, Context context)
throws IOException, InterruptedException {
String[] pair = StringUtils.split(key.toString(), ":");
context.write(new Text(pair[0]),
new Text(StringUtils.join(
new String[] {pair[1], String.valueOf(value.get())}, ":")));
}
}
/**
* Converts the output of step 2 into a map of word and position.
* Flattens the docId => (word:occurrence) pairs to docId => dense
* positional list of occurrences.
*/
public static class Reducer3 extends Reducer<Text,Text,Text,Text> {
private Map<String,Integer> terms = new HashMap<String,Integer>();
@Override
public void setup(Context context)
throws IOException, InterruptedException {
Configuration conf = context.getConfiguration();
FileSystem hdfs = FileSystem.get(conf);
FileStatus[] partFiles = hdfs.listStatus(
new Path(conf.get(TERMS_DIR_KEY)));
for (FileStatus partFile : partFiles) {
if (! partFile.getPath().getName().startsWith("part-r")) {
continue;
}
FSDataInputStream fis = hdfs.open(partFile.getPath());
BufferedReader reader = new BufferedReader(
new InputStreamReader(fis));
String line = null;
int i = 0;
while ((line = reader.readLine()) != null) {
String term = StringUtils.split(line, "\t")[0];
terms.put(term, i);
i++;
}
reader.close();
fis.close();
}
}
@Override
public void reduce(Text key, Iterable<Text> values, Context context)
throws IOException, InterruptedException {
SortedMap<String,Long> frequencies = new TreeMap<String,Long>();
for (Text value : values) {
String[] parts = StringUtils.split(value.toString(), ":");
String word = parts[0];
Long occurrence = new Long(parts[1]);
frequencies.put(word, occurrence);
}
context.write(key, flatten(frequencies));
}
private Text flatten(SortedMap<String,Long> frequencies) {
long[] array = new long[terms.size()];
for (String word : frequencies.keySet()) {
int pos = terms.get(word);
array[pos] = frequencies.get(word);
}
StringBuilder buf = new StringBuilder();
for (int i = 0; i < array.length; i++) {
if (i > 0) {
buf.append(",");
}
buf.append(String.valueOf(array[i]));
}
return new Text(buf.toString());
}
}
/**
* Calling method.
* @param argv command line args.
* @throws Exception if thrown.
*/
public static void main(String[] argv) throws Exception {
Configuration conf = new Configuration();
String[] otherArgs = new GenericOptionsParser(
conf, argv).getRemainingArgs();
if (otherArgs.length != 4) {
System.err.println("Usage: tdmg <prefix> <stopwords> <indir> <outdir>");
System.exit(-1);
}
Path basedir = new Path(otherArgs[0] + otherArgs[2]).getParent();
/* phase 1: convert doc file to (docId:word) => occurrence */
Job job1 = new Job(conf, "phase-1");
job1.getConfiguration().set(STOPWORD_FILE_LOCATION_KEY,
otherArgs[0] + otherArgs[1]);
job1.getConfiguration().set("foo", "bar");
FileInputFormat.addInputPath(job1,
new Path(otherArgs[0] + otherArgs[2]));
FileOutputFormat.setOutputPath(job1, new Path(basedir, "temp1"));
job1.setJarByClass(TermDocumentMatrixGenerator.class);
job1.setMapperClass(Mapper1.class);
job1.setReducerClass(Reducer1.class);
job1.setInputFormatClass(SequenceFileInputFormat.class);
job1.setOutputFormatClass(SequenceFileOutputFormat.class);
job1.setMapOutputKeyClass(Text.class);
job1.setMapOutputValueClass(LongWritable.class);
job1.setOutputKeyClass(Text.class);
job1.setOutputValueClass(LongWritable.class);
job1.setNumReduceTasks(2);
boolean job1Success = job1.waitForCompletion(true);
if (! job1Success) {
System.err.println("Job1 failed, exiting");
System.exit(-1);
}
/* phase 2: write out unique terms with indexes to terms directory */
Job job2 = new Job(conf, "phase-2");
FileInputFormat.addInputPath(job2, new Path(basedir, "temp1"));
FileOutputFormat.setOutputPath(job2, new Path(basedir, "temp2"));
job2.setJarByClass(TermDocumentMatrixGenerator.class);
job2.setMapperClass(Mapper2.class);
job2.setReducerClass(Reducer2.class);
job2.setInputFormatClass(SequenceFileInputFormat.class);
job2.setOutputFormatClass(TextOutputFormat.class);
job2.setMapOutputKeyClass(Text.class);
job2.setMapOutputValueClass(LongWritable.class);
job2.setOutputKeyClass(Text.class);
job2.setOutputValueClass(LongWritable.class);
job2.setNumReduceTasks(2);
boolean job2Success = job2.waitForCompletion(true);
if (! job2Success) {
System.err.println("Job2 failed, exiting");
System.exit(-1);
}
/* phase 3: flatten to docId => occurrence,... */
Job job3 = new Job(conf, "phase-3");
job3.getConfiguration().set(TERMS_DIR_KEY, basedir + "/temp2");
FileInputFormat.addInputPath(job3, new Path(basedir, "temp1"));
FileOutputFormat.setOutputPath(job3,
new Path(otherArgs[0] + otherArgs[3]));
job3.setJarByClass(TermDocumentMatrixGenerator.class);
job3.setMapperClass(Mapper3.class);
job3.setReducerClass(Reducer3.class);
job3.setInputFormatClass(SequenceFileInputFormat.class);
job3.setOutputFormatClass(TextOutputFormat.class);
job3.setMapOutputKeyClass(Text.class);
job3.setMapOutputValueClass(Text.class);
job3.setOutputKeyClass(Text.class);
job3.setOutputValueClass(Text.class);
job3.setNumReduceTasks(2);
System.exit(job3.waitForCompletion(true) ? 0 : 1);
}
}
|
I ran this in local mode and pseudo-distributed mode. I had to change the code a bit for the pseudo-distributed mode (I think mostly its the file names), but here is the script I used to run it.
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 | #!/bin/bash
# Source: ./src/main/scripts/hadoop/run_tdmg.sh
# Script to call Term Document Matrix Generator
## CONFIG ##
M2_REPO=/home/sujit/.m2/repository
HADOOP_HOME=/opt/hadoop-0.20.0
PROJECT_BASEDIR=/home/sujit/src/jtmt
MODE=p # mode can be (l)ocal or (p)seudo-distributed
if [ $MODE == "l" ]; then
PROTOCOL_PREFIX=""
STOPWORD_FILE=$PROJECT_BASEDIR/src/main/resources/stopwords.txt
INPUT_DIR=$PROJECT_BASEDIR/src/test/resources/hac/inputs
OUTPUT_DIR=$PROJECT_BASEDIR/src/test/resources/hac/outputs
TEMP_DIRS=$PROJECT_BASEDIR/src/test/resources/hac/temp*
else
PROTOCOL_PREFIX=hdfs://localhost:54310
STOPWORD_FILE=/user/sujit/tdmg/resources/stopwords.txt
INPUT_DIR=/user/sujit/tdmg/inputs
OUTPUT_DIR=/user/sujit/tdmg/outputs
TEMP_DIRS=/user/sujit/tdmg/temp*
fi
## CONFIG ##
# for local mode
if [ $MODE == "l" ]; then
export HADOOP_CLASSPATH=$CLASSPATH:\
$M2_REPO/org/apache/lucene/lucene-core/2.4.0/lucene-core-2.4.0.jar:\
$M2_REPO/org/apache/lucene-analyzers/2.3.0/lucene-analyzers-2.3.0.jar:\
$M2_REPO/commons-lang/commons-lang/2.1/commons-lang-2.1.jar:\
$PROJECT_BASEDIR/target/jtmt-1.0-SNAPSHOT.jar
fi
cd $HADOOP_HOME
if [ $MODE == "l" ]; then
rm -rf $OUTPUT_DIR
rm -rf $TEMP_DIRS
# no special packaging required for local mode
bin/hadoop jar $PROJECT_BASEDIR/target/jtmt-1.0-SNAPSHOT.jar net.sf.jtmt.indexers.hadoop.TermDocumentMatrixGenerator "" $STOPWORD_FILE $INPUT_DIR $OUTPUT_DIR
else
bin/hadoop fs -rmr $OUTPUT_DIR
bin/hadoop fs -rmr $TEMP_DIRS
bin/hadoop jar $PROJECT_BASEDIR/target/tdm-generator.jar $PROTOCOL_PREFIX $STOPWORD_FILE $INPUT_DIR $OUTPUT_DIR
fi
cd -
unset HADOOP_CLASSPATH
|
Similarities and Differences
The main difference of course is that you extend mapreduce.Mapper and mapreduce.Reducer instead of extending MapReduceBase and implementing either mapred.Mapper or mapred.Reducer. The Context provides a more unified way to pass stuff around, although I had some trouble (which I later fixed) passing parameters to the Mapper and Reducer classes. Another difference seems to be that the ToolRunner is no longer needed.
I did miss the "convenience" Mappers and Reducers (such as LongSumReducer), but I am guessing that they will make their way back in once the old API goes away.
Among the similarities, you still have to package your application code and third party dependencies in the same way as before, something I was hoping would go away once I learned about HADOOP_CLASSPATH, but I guess it makes sense to keep it the way it is.

