In a recent discussion of what the possible applications of R to binary analysis are, the usual visualizations (byte entropy, size of basic blocks, number of times a function is called during a trace, etc) came to mind. Past experiments with tm.plugins.webmining, however, also raised the following question: Why not use the R textmining packages to generate a wordcloud from a disassembled binary?
Why not, indeed.
The objdump disassembler can be used to generate a list of terms from a binary file. The template Ruby code for generating a list of terms is a simple wrapper around objdump:
# generate a space-delimited string of terms occurring in target at 'path'
terms = `objdump -DRTgrstx '#{path}'`.lines.inject([]) { |arr, line|
# ...extract terms from line and append to arr...
arr
}.join(" ")
The R code for generating wordclouds has been covered before. The code for disassembly terms can be more simple, as the terms have already been extracted from the raw text (disassembly):
library('tm')
library('wordcloud')
# term occurrences must be in variable "terms"
corpus <- Corpus(VectorSource(terms))
tdm <- TermDocumentMatrix(corpus)
vec <- sort(rowSums(as.matrix(tdm)), decreasing=TRUE)
df <- data.frame(word=names(vec), freq=vec)
# output file path must be in variable "img_path"
png(file=img_path)
# minimum frequency should be higher than 1 if there are many terms
wordcloud(df$word, df$freq, min.freq=1)
dev.off()
The most interesting terms in a binary are the library functions that are invoked. The following regex will extract the symbol name from call instructions:
terms = `objdump -DRTgrstx '#{path}'`.lines.inject([]) { |arr, line|
arr << $1 if line =~ /<([_[:alnum:]]+)(@[[:alnum:]]+)?>\s*$/
arr
}
When run on /usr/bin/xterm, this generates the following wordcloud:
The other obvious terms in a binary are the instruction mnemonics. The following regex will extract the instruction mnemonics from an objdump disassembly:
terms = `objdump -DRTgrstx '#{path}'`.lines.inject([]) { |arr, line|
arr << $1 if line =~ /^\s*[[:xdigit:]]+:[[:xdigit:]\s]+\s+([[:alnum:]]+)\s*/
arr
}
When run on /usr/bin/xterm, this generates the following wordcloud:
Of course, there is always the possibility of generating a wordcloud from the ASCII strings in a binary. The following Ruby code is a crude attempt at creating a terms string from the output of the strings command:
terms = `strings '#{path}'`.gsub(/[[:punct:]]/, '').lines.to_a .join(' ')
When run on /usr/bin/xterm, this generates the following wordcloud:
Not as nice as the others, but some pre-processing of the strings output would clear that up.
There is, of course, a github for the code. Note that the implementation is in Ruby, using the rsruby gem to interface with R.
Friday, February 7, 2014
Wednesday, February 5, 2014
Finding stock symbols by industry in R
The quantmod package is fantastic, but it has one shortcoming: there is no facility for retrieving information about a specific industry (e.g., "is the entire industry on a downward trend, or just this company?").
Yahoo Finance provides this information via its CSV API; this means it should be easy to retrieve from within R. Details about the API have been provided by the C# yahoo-finance-managed project.
The first step is to get a list of all possible sectors. This is a straightforward Curl download and CSV parse:
library(RCurl)
get.sectors <- function() {
url <- 'http://biz.yahoo.com/p/csv/s_conameu.csv'
csv <- rawToChar(getURLContent(url, binary=TRUE))
df <- read.csv(textConnection(csv))
# sector ID is its index in this alphabetical list
df$ID <- 1:nrow(df)
return(df)
}
Note the use of textConnection() to parse an in-memory string instead of an on-disk file. The binary=TRUE flag causes Curl to return a "raw" object which is converted to a character vector by the rawToChar() call; this is necessary because the CSV file ends with a NULL byte.
The next step is to fetch a list of the industries in each sector. At first, this seems to be straightforward:
get.sector.industries <- function( sector ) {
url <- paste('http://biz.yahoo.com/p/csv',
paste(as.integer(sector), 'conameu.csv', sep=''),
sep='/')
csv <- rawToChar(getURLContent(url, binary=TRUE))
df <- read.csv(textConnection(csv))
# fix broken Industry names
df[,'Industry'] <- gsub(' +', ' ', df[,'Industry'])
# default (incorrect) ID column
df$ID <- (sector * 100) + 1:nrow(df)
df$Sector <- sector
return(df)
}
Unfortunately, there is one problem: the industry IDs are not based on the index value. In fact, there does not seem to be a way to obtain the industry IDs using the Yahoo Finance API, which appears to be a pretty egregious oversight.
Yahoo Finance provides an alphabetical list of industries in all sectors; the URL for each industry entry contains its ID. This means that the page can be parsed in order to build a list of industries and their IDs.
The code is a little hairy, involving a couple of XPath queries to extract the URLs and their descriptions:
library(XML)
get.industry.ids <- function() {
html <- htmlParse('http://biz.yahoo.com/ic/ind_index_alpha.htm')
# extract description from A tags
html.names <- as.vector(xpathSApply(html, "//td/a/font", xmlValue))
# extract URL from A tags
html.urls <- as.vector(xpathSApply(html, "//td/a/font/../@href"))
if (length(html.names) != length(html.urls)) {
warning(paste("Got", length(html.names), "names but",
length(html.urls), "URLs"))
}
html.names <- gsub("\n", " ", html.names)
html.urls <- gsub("http://biz.yahoo.com/ic/([0-9]+).html", "\\1", html.urls)
df <- data.frame(Name=character(length(html.urls)),
ID=numeric(length(html.urls)), stringsAsFactors=FALSE)
for (i in 1:length(html.urls)) {
url = html.urls[i]
val = suppressWarnings(as.numeric(url))
if (! is.na(val) ) {
df[i,'Name'] = html.names[i]
df[i,'ID'] = val
}
}
return(df)
}
In this function, htmlParse() was used to download the web page instead of Curl. This is necessary because the webpage contains one or more non-trailing NULL bytes; rawToChar() can only strip trailing NULL bytes. The parser in htmlParse() is able to handle the NULL bytes just fine.
With this function, the IDs of industries can be set as follows:
df <- get.sector.industries( sector.id )
id.df <- get.industry.ids()
for (i in 1:nrow(id.df)) {
name <- id.df[i, 'Name']
if (nrow(df[df$Industry == name,]) > 0) {
df[df$Industry == name, 'ID'] <- id.df[i, 'ID']
}
}
It is now possible to build a dataframe that contains the industries of all of the sectors:
df.sectors <- get.sectors()
id.df <- get.industry.ids()
df.industries <- NULL
for (id in df.sectors) {
df <- get.sector.industries(id)
name <- id.df[i, 'Name']
if (nrow(df[df$Industry == name,]) > 0) {
df[df$Industry == name, 'ID'] <- id.df[i, 'ID']
}
This list is probably not going to change much, so the dataframe can be stored for reuse in an .RData object.
The final step, getting the stock symbols for a specific industry, is much more straightforward:
get.industry.symbols <- function(id) {
url <- paste('http://biz.yahoo.com/p/csv',
paste(as.integer(id), 'conameu.csv', sep=''),
sep='/')
csv <- rawToChar(getURLContent(url, binary=TRUE))
df <- read.csv(textConnection(csv))
return(df)
}
As usual, there is a github for the code.
One final note: the sector and industry data is also available via the FinViz API. Yahoo Finance was selected for this project in order to be compatible with the quantmod data.
Yahoo Finance provides this information via its CSV API; this means it should be easy to retrieve from within R. Details about the API have been provided by the C# yahoo-finance-managed project.
The first step is to get a list of all possible sectors. This is a straightforward Curl download and CSV parse:
library(RCurl)
get.sectors <- function() {
url <- 'http://biz.yahoo.com/p/csv/s_conameu.csv'
csv <- rawToChar(getURLContent(url, binary=TRUE))
df <- read.csv(textConnection(csv))
# sector ID is its index in this alphabetical list
df$ID <- 1:nrow(df)
return(df)
}
Note the use of textConnection() to parse an in-memory string instead of an on-disk file. The binary=TRUE flag causes Curl to return a "raw" object which is converted to a character vector by the rawToChar() call; this is necessary because the CSV file ends with a NULL byte.
The next step is to fetch a list of the industries in each sector. At first, this seems to be straightforward:
get.sector.industries <- function( sector ) {
url <- paste('http://biz.yahoo.com/p/csv',
paste(as.integer(sector), 'conameu.csv', sep=''),
sep='/')
csv <- rawToChar(getURLContent(url, binary=TRUE))
df <- read.csv(textConnection(csv))
# fix broken Industry names
df[,'Industry'] <- gsub(' +', ' ', df[,'Industry'])
# default (incorrect) ID column
df$ID <- (sector * 100) + 1:nrow(df)
df$Sector <- sector
return(df)
}
Unfortunately, there is one problem: the industry IDs are not based on the index value. In fact, there does not seem to be a way to obtain the industry IDs using the Yahoo Finance API, which appears to be a pretty egregious oversight.
Yahoo Finance provides an alphabetical list of industries in all sectors; the URL for each industry entry contains its ID. This means that the page can be parsed in order to build a list of industries and their IDs.
The code is a little hairy, involving a couple of XPath queries to extract the URLs and their descriptions:
library(XML)
get.industry.ids <- function() {
html <- htmlParse('http://biz.yahoo.com/ic/ind_index_alpha.htm')
# extract description from A tags
html.names <- as.vector(xpathSApply(html, "//td/a/font", xmlValue))
# extract URL from A tags
html.urls <- as.vector(xpathSApply(html, "//td/a/font/../@href"))
if (length(html.names) != length(html.urls)) {
warning(paste("Got", length(html.names), "names but",
length(html.urls), "URLs"))
}
html.names <- gsub("\n", " ", html.names)
html.urls <- gsub("http://biz.yahoo.com/ic/([0-9]+).html", "\\1", html.urls)
df <- data.frame(Name=character(length(html.urls)),
ID=numeric(length(html.urls)), stringsAsFactors=FALSE)
for (i in 1:length(html.urls)) {
url = html.urls[i]
val = suppressWarnings(as.numeric(url))
if (! is.na(val) ) {
df[i,'Name'] = html.names[i]
df[i,'ID'] = val
}
}
return(df)
}
In this function, htmlParse() was used to download the web page instead of Curl. This is necessary because the webpage contains one or more non-trailing NULL bytes; rawToChar() can only strip trailing NULL bytes. The parser in htmlParse() is able to handle the NULL bytes just fine.
With this function, the IDs of industries can be set as follows:
df <- get.sector.industries( sector.id )
id.df <- get.industry.ids()
for (i in 1:nrow(id.df)) {
name <- id.df[i, 'Name']
if (nrow(df[df$Industry == name,]) > 0) {
df[df$Industry == name, 'ID'] <- id.df[i, 'ID']
}
}
It is now possible to build a dataframe that contains the industries of all of the sectors:
df.sectors <- get.sectors()
id.df <- get.industry.ids()
df.industries <- NULL
for (id in df.sectors) {
df <- get.sector.industries(id)
name <- id.df[i, 'Name']
if (nrow(df[df$Industry == name,]) > 0) {
df[df$Industry == name, 'ID'] <- id.df[i, 'ID']
}
if (is.null(ind.df)) {
ind.df <- df
} else {
ind.df <- rbind(ind.df, df)
}
}This list is probably not going to change much, so the dataframe can be stored for reuse in an .RData object.
The final step, getting the stock symbols for a specific industry, is much more straightforward:
get.industry.symbols <- function(id) {
url <- paste('http://biz.yahoo.com/p/csv',
paste(as.integer(id), 'conameu.csv', sep=''),
sep='/')
csv <- rawToChar(getURLContent(url, binary=TRUE))
df <- read.csv(textConnection(csv))
return(df)
}
As usual, there is a github for the code.
One final note: the sector and industry data is also available via the FinViz API. Yahoo Finance was selected for this project in order to be compatible with the quantmod data.
Sunday, February 2, 2014
Daily stock symbol reports with R
This is a simple R script that uses the quantmod package to look up stock symbols on Yahoo Finance, and the sendmailR package to send an email alert if the latest stock price ("Last" in the Yahoo report) is below a "buy" threshold or above a "sell" threshold.
The input file format is tab-delimited with three columns: Symbol, BuyAt, SellAt. There is no need for a header column. For example:
AAPL 300 750
BA 65 90
...
The function that does all the work is symbol.report. This reads the input file containing buy and sell thresholds, performs a Yahoo Finance query on all symbols in the file, and generates a dataframe with the details (BuyAt, SellAt, Open, Close, Last, etc) of every symbol whose latest price is either below the buy threshold, or above the sell threshold.
library(quantmod)
symbol.report <- function(filename, header=FALSE, sep = "\t") {
watch.df <- read.delim(filename, header=header, sep=sep)
colnames(watch.df) <- c('Symbol', 'BuyAt', 'SellAt')
quote.df <- getQuote(paste(watch.df$Symbol, collapse=';'))
quote.df$Symbol <- rownames(quote.df)
df <- merge(watch.df, quote.df)
df[(df$Last <= df$BuyAt) | (df$SellAt > 0 & df$Last >= df$SellAt), ]
}
The symbol.report function is invoked by symbol.alert, which will send an email to the provided address if the dataframe returned by symbol.report is not empty. If an email address is not provided, the dataframe will be printed to STDOUT.
library(sendmailR)
symbol.alert <- function(filename, email=NULL, verbose=FALSE) {
df <- symbol.report(filename)
if (nrow(df) == 0) {
return(df)
}
if ( is.null(email) ) {
print(df)
} else {
sendmail(# from: fake email address
paste('<', "r.script@nospam.org", '>', sep=''),
# to: provided email address
paste('<', email, '>', sep=''),
# subject
"SYMBOL ALERT",
# body
capture.output(print(df, row.names=FALSE)),
# SMTP server (gmail)
control=list(smtpServer='ASPMX.L.GOOGLE.COM'),
verbose=verbose)
}
return(df)
}
A few things to note here:
* a fake email address is used as the From address, allowing easy filtering of these emails
* the SMTP server used is the GMail server, which may not be appropriate for some users
This function can be called from a shell script in a cron job, invoking R with the --vanilla option:
R --vanilla -e "source('/home/me/symbol.alert.R'); ticker.email.alert('/home/me/monitoried_symbols.dat', 'me@gmail.com')"
And again, there is a github for the code.
The input file format is tab-delimited with three columns: Symbol, BuyAt, SellAt. There is no need for a header column. For example:
AAPL 300 750
BA 65 90
...
The function that does all the work is symbol.report. This reads the input file containing buy and sell thresholds, performs a Yahoo Finance query on all symbols in the file, and generates a dataframe with the details (BuyAt, SellAt, Open, Close, Last, etc) of every symbol whose latest price is either below the buy threshold, or above the sell threshold.
library(quantmod)
symbol.report <- function(filename, header=FALSE, sep = "\t") {
watch.df <- read.delim(filename, header=header, sep=sep)
colnames(watch.df) <- c('Symbol', 'BuyAt', 'SellAt')
quote.df <- getQuote(paste(watch.df$Symbol, collapse=';'))
quote.df$Symbol <- rownames(quote.df)
df <- merge(watch.df, quote.df)
df[(df$Last <= df$BuyAt) | (df$SellAt > 0 & df$Last >= df$SellAt), ]
}
The symbol.report function is invoked by symbol.alert, which will send an email to the provided address if the dataframe returned by symbol.report is not empty. If an email address is not provided, the dataframe will be printed to STDOUT.
library(sendmailR)
symbol.alert <- function(filename, email=NULL, verbose=FALSE) {
df <- symbol.report(filename)
if (nrow(df) == 0) {
return(df)
}
if ( is.null(email) ) {
print(df)
} else {
sendmail(# from: fake email address
paste('<', "r.script@nospam.org", '>', sep=''),
# to: provided email address
paste('<', email, '>', sep=''),
# subject
"SYMBOL ALERT",
# body
capture.output(print(df, row.names=FALSE)),
# SMTP server (gmail)
control=list(smtpServer='ASPMX.L.GOOGLE.COM'),
verbose=verbose)
}
return(df)
}
A few things to note here:
* a fake email address is used as the From address, allowing easy filtering of these emails
* the SMTP server used is the GMail server, which may not be appropriate for some users
This function can be called from a shell script in a cron job, invoking R with the --vanilla option:
R --vanilla -e "source('/home/me/symbol.alert.R'); ticker.email.alert('/home/me/monitoried_symbols.dat', 'me@gmail.com')"
And again, there is a github for the code.
Sunday, October 27, 2013
Qt4: Cannot mix incompatible Qt library
This problem occurs every now and then when using closed-source Qt4 binaries or libraries:
bash$ LD_LIBRARY_PATH=./lib bin/TestBench
Cannot mix incompatible Qt library (version 0x40801) with this library (version 0x40803)
Aborted (core dumped)
Setting LD_LIBRARY_PATH in order to override the Qt4 library never works, even though it should.
It turns out that this error has nothing to do with the proprietary software being linked to an incompatible Qt version. Instead, the user's Qt theme (often Oxygen) is incompatible with the Qt libraries shipped with the proprietary software.
This can be solved in two ways. The permanent way is to run qtconfig-qt4 and choose another theme (e.g. Cleanlooks, which always seems to work).
The second is to pass a compatible theme to the proprietary software using the -style command-line argument:
LD_LIBRARY_PATH=./lib bin/TestBench -style=CleanLooks
This will override the theme only for this invocation of the application.
Labels:
qt
Thursday, July 25, 2013
Including binary files in an R package
The R package format provides support for data in standard formats (.R, .Rdata, .csv) in the data/ directory. Unfortunately, data in unsupported formats (e.g. audio files, images, SQLite databases) is ignored by the package build command.
The solution, as hinted at in the manual, is to place such data in the inst/extdata/ directory:
"It should not be used for other data files needed by the package, and the convention has grown up to use directory inst/extdata for such files."
Using a SQLite database file as an example, an R package can provide a default database by including the path to the built-in database as a default parameter to functions. Because the path is determined at runtime, the best solution is to include an exported function that provides the path to the built-in database:
pkg.default.database <- font="" function="">->
system.file('extdata', 'default_db.sqlite', package='pkg')
}
In this example, the package name is pkg, and the SQLite database file is inst/extdata/default_db.sqlite.
Package functions that take a path to the SQLite database can then invoke this function as a default parameter. For example:
pkg.fetch.rows <- db="pk.default.database()," font="" function="" limit="NULL)">->
# Connect to database
conn <- db="" dbconnect="" font="" ite="">->
if (! dbExistsTable(conn, 'sensor_data')) {
warning(paste('Table SENSOR_DATA does not exist in', db))
dbDisconnect(conn)
return(NULL)
}
# build query for table SENSOR_DATA
query <- font="" from="" sensor_data="">->
if (! is.null(where) ) {
query <- font="" paste="" query="" where="">->
}
# send query and retrieve rows as a dataframe
ds <- conn="" dbsendquery="" font="" query="">->
df <- ds="" fetch="" n="-1)</font">->
# cleanup
dbClearResult(ds)
dbDisconnect(conn)
return(df)
}
The solution, as hinted at in the manual, is to place such data in the inst/extdata/ directory:
"It should not be used for other data files needed by the package, and the convention has grown up to use directory inst/extdata for such files."
Using a SQLite database file as an example, an R package can provide a default database by including the path to the built-in database as a default parameter to functions. Because the path is determined at runtime, the best solution is to include an exported function that provides the path to the built-in database:
pkg.default.database <- font="" function="">->
system.file('extdata', 'default_db.sqlite', package='pkg')
}
In this example, the package name is pkg, and the SQLite database file is inst/extdata/default_db.sqlite.
Package functions that take a path to the SQLite database can then invoke this function as a default parameter. For example:
pkg.fetch.rows <- db="pk.default.database()," font="" function="" limit="NULL)">->
# Connect to database
conn <- db="" dbconnect="" font="" ite="">->
if (! dbExistsTable(conn, 'sensor_data')) {
warning(paste('Table SENSOR_DATA does not exist in', db))
dbDisconnect(conn)
return(NULL)
}
# build query for table SENSOR_DATA
query <- font="" from="" sensor_data="">->
if (! is.null(where) ) {
query <- font="" paste="" query="" where="">->
}
# send query and retrieve rows as a dataframe
ds <- conn="" dbsendquery="" font="" query="">->
df <- ds="" fetch="" n="-1)</font">->
# cleanup
dbClearResult(ds)
dbDisconnect(conn)
return(df)
}
Monday, June 17, 2013
(English) word-clouds in R
The R wordcloud package can be used to generate static images similar to tag-clouds. These are a fun way to visualize document contents, as demonstrated on the R Data Mining website and at the One R Tip A Day site.
Running the sample code from these examples on any real English prose results in lists of words that are far from satisfactory, even when using a stemmer. English is a difficult language to parse, especially when the source is nontechnical writing or, worse, a transcript. In this particular case, an entirely accurate parsing of English isn't necessary; the wordcloud generation only has to be intelligent enough to not make the viewer snort in derision.
To begin with, use the R Text Mining package to load a directory of documents to be analyzed:
library(tm)
wc_corpus <- Corpus(DirSource('/tmp/wc_documents'))
This creates a Corpus containing all files in the directory supplied to DirSource. The files are assumed to be in plaintext; for different formats, use the Corpus readerControl argument:
wc_corpus <- Corpus(DirSource('/tmp/wc_documents'), readerControl=readPDF)
If the text is already loaded in R, then a VectorSource can be of course be used:
wc_corpus <- Corpus(VectorSource(data_string))
Next, the text in the Corpus must be normalized. This involves the following steps:
# fix_contractions is defined later in the article
wc_corpus <- tm_map(wc_corpus, fix_contractions)
wc_corpus <- tm_map(wc_corpus, removePunctuation)
wc_corpus <- tm_map(wc_corpus, removeWords, stopwords('english'))
# Not executed: stem the words in the corpus
# wc_corpus <- tm_map(wc_corpus, stemDocument)
This code makes use of the tm_map function, which invokes a function for every document in the Corpus.
A support function is required to remove contractions from the Corpus. Note that this step must be performed before punctuation is removed, or it will be more difficult to detect contractions.
The purpose of the fix_contractions function is to expand all contractions to their "formal English" equivalents: don't to do not, we'll to we will, etc. The following function uses gsub to perform this expansion, except in the case of possessives and plurals ('s) which are simply removed.
fix_contractions <- function(doc) {
# "won't" is a special case as it does not expand to "wo not"
doc <- gsub("won't", "will not", doc)
doc <- gsub("n't", " not", doc)
doc <- gsub("'ll", " will", doc)
doc <- gsub("'re", " are", doc)
doc <- gsub("'ve", " have", doc)
doc <- gsub("'m", " am", doc)
# 's could be is or possessive: it has no expansion
doc <- gsub("'s", "", doc)
return(doc)
}
The Corpus has now been normalized, and can be used to generate a list of words along with counts of their occurrence. First, a TermDocument matrix is created; next, a Word-Frequency Vector (a list of the number of occurrences of each word) is generated. Each element in the vector is the number of occurrences for a specific word, and the name of the element is the word itself (use names(v) to verify this).
td_mtx <- TermDocumentMatrix(wc_corpus, control = list(minWordLength = 3))
v <- sort(rowSums(as.matrix(td_mtx)), decreasing=TRUE)
At this point, the vector is a list of all words in the document, along with their frequency counts. This can be cleaned up by removing obvious plurals (dog, dogs; address, addresses; etc), and adding their occurrence count to the singular case.
This doesn't have to be completely accurate (it's only a wordcloud, after all), and it is not necessary to convert plural words to singular if there is no singular form present. The following function will check each word in the Word-Frequency Vector to see if a plural form of that word (specifically, the word followed by s or es) exists in the Vector as well. If so, the frequency count for the plural form is added to the frequency count for the singular form, and the plural form is removed from the Vector.
aggregate.plurals <- function (v) {
aggr_fn <- function(v, singular, plural) {
if (! is.na(v[plural])) {
v[singular] <- v[singular] + v[plural]
v <- v[-which(names(v) == plural)]
}
return(v)
}
for (n in names(v)) {
n_pl <- paste(n, 's', sep='')
v <- aggr_fn(v, n, n_pl)
n_pl <- paste(n, 'es', sep='')
v <- aggr_fn(v, n, n_pl)
}
return(v)
}
The function is applied to the Word-Frequency Vector as follows:
v <- aggregate.plurals(v)
All that remains is to create a dataframe of the word frequencies, and supply that to the wordcloud function in order to generate the wordcloud image:
df <- data.frame(word=names(v), freq=v)
library(wordcloud)
wordcloud(df$word, df$freq, min.freq=3)
It goes without saying that the default R graphics device can be changed to save the file. An example for PNG output:
png(file='wordcloud.png', bg='transparent')
wordcloud(df$word, df$freq, min.freq=3)
dev.off()
Running the sample code from these examples on any real English prose results in lists of words that are far from satisfactory, even when using a stemmer. English is a difficult language to parse, especially when the source is nontechnical writing or, worse, a transcript. In this particular case, an entirely accurate parsing of English isn't necessary; the wordcloud generation only has to be intelligent enough to not make the viewer snort in derision.
To begin with, use the R Text Mining package to load a directory of documents to be analyzed:
library(tm)
wc_corpus <- Corpus(DirSource('/tmp/wc_documents'))
This creates a Corpus containing all files in the directory supplied to DirSource. The files are assumed to be in plaintext; for different formats, use the Corpus readerControl argument:
wc_corpus <- Corpus(DirSource('/tmp/wc_documents'), readerControl=readPDF)
If the text is already loaded in R, then a VectorSource can be of course be used:
wc_corpus <- Corpus(VectorSource(data_string))
Next, the text in the Corpus must be normalized. This involves the following steps:
- convert all text to lowercase
- expand all contractions
- remove all punctuation
- remove all "noise words"
The last step requires detecting what are known as "stop words" : words in a language which provide no information (articles, prepositions, and extremely common words). Note that in most text processing, a fifth step would be added to stem the words in the Corpus; in generating word clouds, this produces undesirable output, as the stemmed words tend to be roots that are not recognizable as actual English words.
The following code performs these steps:
wc_corpus <- tm_map(wc_corpus, tolower)# fix_contractions is defined later in the article
wc_corpus <- tm_map(wc_corpus, fix_contractions)
wc_corpus <- tm_map(wc_corpus, removePunctuation)
wc_corpus <- tm_map(wc_corpus, removeWords, stopwords('english'))
# Not executed: stem the words in the corpus
# wc_corpus <- tm_map(wc_corpus, stemDocument)
This code makes use of the tm_map function, which invokes a function for every document in the Corpus.
A support function is required to remove contractions from the Corpus. Note that this step must be performed before punctuation is removed, or it will be more difficult to detect contractions.
The purpose of the fix_contractions function is to expand all contractions to their "formal English" equivalents: don't to do not, we'll to we will, etc. The following function uses gsub to perform this expansion, except in the case of possessives and plurals ('s) which are simply removed.
fix_contractions <- function(doc) {
# "won't" is a special case as it does not expand to "wo not"
doc <- gsub("won't", "will not", doc)
doc <- gsub("n't", " not", doc)
doc <- gsub("'ll", " will", doc)
doc <- gsub("'re", " are", doc)
doc <- gsub("'ve", " have", doc)
doc <- gsub("'m", " am", doc)
# 's could be is or possessive: it has no expansion
doc <- gsub("'s", "", doc)
return(doc)
}
The Corpus has now been normalized, and can be used to generate a list of words along with counts of their occurrence. First, a TermDocument matrix is created; next, a Word-Frequency Vector (a list of the number of occurrences of each word) is generated. Each element in the vector is the number of occurrences for a specific word, and the name of the element is the word itself (use names(v) to verify this).
td_mtx <- TermDocumentMatrix(wc_corpus, control = list(minWordLength = 3))
v <- sort(rowSums(as.matrix(td_mtx)), decreasing=TRUE)
At this point, the vector is a list of all words in the document, along with their frequency counts. This can be cleaned up by removing obvious plurals (dog, dogs; address, addresses; etc), and adding their occurrence count to the singular case.
This doesn't have to be completely accurate (it's only a wordcloud, after all), and it is not necessary to convert plural words to singular if there is no singular form present. The following function will check each word in the Word-Frequency Vector to see if a plural form of that word (specifically, the word followed by s or es) exists in the Vector as well. If so, the frequency count for the plural form is added to the frequency count for the singular form, and the plural form is removed from the Vector.
aggregate.plurals <- function (v) {
aggr_fn <- function(v, singular, plural) {
if (! is.na(v[plural])) {
v[singular] <- v[singular] + v[plural]
v <- v[-which(names(v) == plural)]
}
return(v)
}
for (n in names(v)) {
n_pl <- paste(n, 's', sep='')
v <- aggr_fn(v, n, n_pl)
n_pl <- paste(n, 'es', sep='')
v <- aggr_fn(v, n, n_pl)
}
return(v)
}
The function is applied to the Word-Frequency Vector as follows:
v <- aggregate.plurals(v)
All that remains is to create a dataframe of the word frequencies, and supply that to the wordcloud function in order to generate the wordcloud image:
df <- data.frame(word=names(v), freq=v)
library(wordcloud)
wordcloud(df$word, df$freq, min.freq=3)
It goes without saying that the default R graphics device can be changed to save the file. An example for PNG output:
png(file='wordcloud.png', bg='transparent')
wordcloud(df$word, df$freq, min.freq=3)
dev.off()
The techniques used previously to create a standalone sentiment analysis command-line utility can be used in this case as well.
Labels:
data science,
r,
text analysis
Friday, June 14, 2013
Git Trick: Preview before pull
A little out of sync with your teammates? Not sure if that next git pull is going to send you into a half-hour of merging?
Use git-fetch and git-diff to see what evils await you:
git fetch
git diff origin/master
As usual, difftool can be used to launch a preferred diff utility (*cough*meld*cough*).
git diff origin/master
git diff --stat origin/master
...or --dirstat to see what directories have changed:
git diff --dirstat origin/master
Use git-fetch and git-diff to see what evils await you:
git fetch
git diff origin/master
As usual, difftool can be used to launch a preferred diff utility (*cough*meld*cough*).
git diff origin/master
To see just what files have changed, use the --stat option:
git diff --stat origin/master
...or --dirstat to see what directories have changed:
git diff --dirstat origin/master
With any luck, everything is more or less in sync and you can proceed with your usual git pull.
For those looking for something to add to their .bashrc:
alias git-dry-run='git fetch && git diff --stat origin/master'
For those looking for something to add to their .bashrc:
alias git-dry-run='git fetch && git diff --stat origin/master'
Labels:
git
Subscribe to:
Posts (Atom)


