Showing posts with label lucene. Show all posts
Showing posts with label lucene. Show all posts

Sunday, May 29, 2016

Elasticsearch based Image Search using RGB Signatures


In my previous post, I described some experiments I was doing to reduce images to a Bag of Visual Words (BOVW). My goal is to build a Content Based Image Retrieval (CBIR), i.e., a system that searches images based on their pixel content rather than text captions or tags associated with them. Furthermore, I would like to use a standard text search engine to do this - a lot of effort has gone into making these engines robust and scalable. So if I model the image search as a text search over a BOVW and deploy it to one of these engines, I get the robust and scalable part for free.

Just like text search, image search is also a balance between precision and recall. You do want the "right" result to appear on top, but you also want to see other results "like" the one you asked for. My attempt to model this fuzziness is to bin the pixels into coarser buckets along each channel; that way this post-processed image looks a little more like other images (thus improving recall) but still looks more similar to similar images than dissimilar images (thus not impacting precision too much).

In my previous post, I had experimented with KMeans clustering for binning the pixels along each channel, but it turned out to be too intensive when run across my entire butterfly corpus of 200 images from Photorack. In any case, I read later that KMeans is not very effective in a single dimension, so I switched to binning the pixels along each channel into 25 equal sized bins. At the end of this process, each image becomes a document composed of a vocabulary of 75 unique "words". Here is the code to read the images and write the corresponding data for loading into a search index.

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# -*- coding: utf-8 -*-
# Source: es_build_rgb.py
import matplotlib.pyplot as plt
import os

import image_search_utils

INPUT_DIR = "../data/butterflies"
OUTPUT_FILE = "../data/butterflies_rgb.txt"

fout = open(OUTPUT_FILE, 'wb')
for fname in os.listdir(INPUT_DIR):
    print("Processing file: %s" % (fname))
    img = plt.imread(os.path.join(INPUT_DIR, fname))
    words = image_search_utils.get_vector_rgb(img)
    words_str = " ".join([w[0] + "|" + ("%.3f" % (w[1])) for w in words])
    fout.write("%s\t%s\n" % (fname, words_str))
fout.close()

For convenience, I factored some of the common functionality into a utils package. One of these is the functionality to convert an image of shape (640, 480, 3) into a vector as described above. We need to do the same transformation on an incoming image as well, so this is also called by the search code. The code for the package 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
# -*- coding: utf-8 -*-
# Source: image_search_utils.py
from __future__ import division, print_function
import collections
import numpy as np
import operator

def get_vector_rgb(img):
    """ Vectorizes an RGB image. Each channel's pixel intensities are
        binned separately into 25 equal size bins, and the frequencies
        counted. The end result is a vector of bin "pixel-words" that 
        are formed by one of ["r", "g", "b"] and the mid point of the
        bin. Each such pixel-word is associated with its frequency,
        arbitarily normalized to 100. Output is a list of (pixel-word,
        normalized-frequency) tuples ordered by highest to lowest 
        normalized frequency.
    """
    colors = ["r", "g", "b"]
    bins = np.linspace(0, 256, 25)
    means = 0.5 * (bins[1:] + bins[:-1]).astype("uint8")
    words = []
    for i in range(len(colors)):
        px_orig = img[:, :, i].flatten()
        labels = np.searchsorted(bins, px_orig)
        px_reduced = np.choose(labels.ravel(), means, 
                               mode="clip").astype("uint8").tolist()
        counter = collections.Counter(px_reduced)
        words.extend([(colors[i] + str(x[0]), x[1]) for x in counter.items()])
    words_sorted = sorted(words, key=operator.itemgetter(1), reverse=True)
    max_freq = words_sorted[0][1]
    words_sorted = [(x[0], 100.0 * x[1] / max_freq) for x in words_sorted]
    return words_sorted
    
def search(vec, es, index_name, doc_type, top_n=35, start=0, size=10):
    """ Does a payload search on the underly Elasticsearch index.
        The top N features of the image vector are used to search.
        The function query called implements cosine similarity so
        the output scores are between 1 and 0. Output is a list of
        file names and scores tuples, ordered by descending order 
        of score.
    """
    top_vec = vec[0:top_n]
    top_vec_as_text = " ".join([x[0] for x in top_vec])
    top_vec_as_param = ",".join(["\""+x[0]+"|"+str(x[1])+"\"" for x in top_vec])
    query = """
{
    "from": %d,
    "size": %d,
    "query": {
        "function_score": {
            "query": {
                "match": {
                    "imgsig": "%s"
                }
            },
            "script_score": {
                "script": {
                    "lang": "groovy",
                    "file": "payload",
                    "params": {
                        "params": [ %s ]
                    }
                }
            }
        }
    }
}
    """ % (start, size, top_vec_as_text, top_vec_as_param)
    resp = es.search(index=index_name, doc_type=doc_type, body=query)
    hits = resp["hits"]["hits"]
    return [(hit["_source"]["filename"], hit["_score"]) for hit in hits]

The output of the vectorization step looks something like this. Each line represents a single image. The record consists of the file name followed by a tab, then followed by 75 pixel words and their frequencies separated by a pipe. Each pixel word starts with either r, g, or b to indicate the layer. The number following the character is the mid point of one of the bins. The frequency has been somewhat arbitarily normalized to a top value of 100. In retrospect I don't think I needed to do this.

1
2
3
4
5
1132868439-121.jpg     g122|100.000 r122|81.176 b122|71.557 r112|31.578 ...
1132868439-1210.jpg    r122|100.000 g122|94.113 b122|92.171 b37|24.449 ...
1132868439-12100.jpg   r122|100.000 g122|93.931 b122|92.702 b37|16.903 ...
1132868439-12101.jpg   r122|100.000 g122|96.838 b122|95.064 g26|34.409 ...
1132868439-12102.jpg   b122|100.000 g122|95.313 r122|94.820 r69|17.510 ...

For the index, I used Elasticsearch (ES) 2.3.3. Looking at the data format above, you probably guessed that I plan to use Lucene's Payloads feature. I have used Payloads before with Solr, but I am using ES more nowadays, so I figured it would be good to explore how to use ES for Payloads as well.

With the help of the OReilly ES Book (a freebie from Elasticon 2016), and some awesome advice on StackOverflow, I was finally able to get this data loaded. The code for creating the index schema and loading the data is shown below. If you look past the boilerplate, I am basically just declaring the analyzer chain for payload data, and declaring that I have two fields in my index - filename which is a string (not text), and imgsig which is a payload field. Then I read the file I just generated and write the records into the index.

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
# -*- coding: utf-8 -*-
# Source: es_create_rgb.py + es_load_rgb.py
import elasticsearch

es = elasticsearch.Elasticsearch(hosts=[{
    "host": "localhost",
    "port": "9200"
}])
create_index = """
{
    "settings": {
        "analysis": {
            "analyzer": {
                "payloads": {
                    "type": "custom",
                    "tokenizer": "whitespace",
                    "filter": [
                        "lowercase",
                        "delimited_payload_filter"
                    ]
                }
            }
        }
    },
    "mappings": {
        "rgb": {
            "properties": {
                "filename": {
                    "type": "string",
                    "index": "not_analyzed"
                },
                "imgsig": {
                    "type": "string",
                    "analyzer": "payloads",
                    "term_vector": "with_positions_offsets_payloads"
                }
            }
        }
    }
}
"""
resp = es.indices.create(index="butterflies", ignore=400, body=create_index)
print resp

fin = open("../data/butterflies_rgb.txt", 'rb')
line_nbr = 1
for line in fin:
    filename, imgsig = line.strip().split("\t")
    es.index(index="butterflies", doc_type="rgb", id=line_nbr, body={
        "filename": "%s" % (filename),
        "imgsig": "%s" % (imgsig)
    })
    line_nbr += 1
fin.close()

For querying the data, however, the advice on Stack Overflow did not work for me. Specifically, ES complained that it couldn't compile the Groovy script. I ended up moving the Groovy script out of the request and into the config/scripts directory of the ES server per the ES Scripting page docs. I also ended up modifying the script a little to make it emit cosine similarities scores instead of the sum of payload scores of the matched result as it was doing before. My initial objective was to make the score reflect the importance of the query image vector elements as well, but then I realized I could just normalize the number to get the cosine similarity. Here is the Groovy script for payload scoring. Some of the functions used are explained in more detail on the ES Advanced Scripting docs.

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
numer = 0.0;
denom_l = 0.0;
denom_r = 0.0;
for (param in params) {
    def (word, weight) = param.tokenize('|');
    weight_l = weight.toFloat();
    denom_l = denom_l + (weight_l * weight_l);
    termInfo = _index["imgsig"].get(word, _PAYLOADS);
    for (pos in termInfo) {
        weight_r = pos.payloadAsFloat(0);
        numer = numer + (weight_l * weight_r);
        denom_r = denom_r + (weight_r * weight_r);
    }
}
return numer / Math.sqrt(denom_l * denom_r);

The image_search_utils package above contains the query to call this scoring function. A typical query looks like this. The query part matches the records which have the words r80 and r90 together, and the params provide the source words and frequencies to match with. Remember that each image can have a maximum of 75 features and are ordered by importance. So we can increase precision by increasing the number of features in our query image and increase recall by decreasing the number of features.

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
    "query": {
        "function_score": {
            "query": {
                "match": {
                    "imgsig": "r80 r90"
                }
            },
            "script_score": {
                "script": {
                    "lang": "groovy",
                    "file": "payload",
                    "params": {
                        "params": [ "r80|10.0", "r90|10.0" ]
                    }
                }
            }
        }
    }
}

For data exploration, I built a little web application with CherryPy. The main page shows me thumbnails of all the images in my corpus. Clicking on an image does a search using that image as the query. Here is the code for the web application.

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
# -*- coding: utf-8 -*-
# Source: image_search_web.py
from __future__ import print_function
import cherrypy
import elasticsearch
import matplotlib.pyplot as plt
import os

import image_search_utils

IMAGE_DIR = "/full/path/to/image-search/data/butterflies"
ES_HOST = "127.0.0.1"
ES_PORT = "9200"
ES_INDEXNAME = "butterflies"
ES_DOCTYPE = "rgb"

class ImageSearchService(object):
    
    def __init__(self):
        self.img_fnames = self._load_images()
        self.es = elasticsearch.Elasticsearch(hosts=[{
            "host": ES_HOST,
            "port": ES_PORT
        }])
        self.index_name = ES_INDEXNAME
        self.doc_type = ES_DOCTYPE

    def _load_images(self):
        img_fnames = []
        for fname in os.listdir(IMAGE_DIR):        
            img_fnames.append(fname)
        return img_fnames

    @cherrypy.expose
    def index(self):
        html = ("""<table cellspacing="0" cellpadding="0" border="1" width="100%">""")
        for i in range(20):
            html += ("""<tr>""")
            for j in range(10):
                curr_fname = self.img_fnames[i * 10 + j]
                html += ("""<td><a href="/search?q=%s"><img src="images/%s" width="100" height="75"/></a></td>""" % 
                    (curr_fname, curr_fname))
            html += ("""</tr>""")
        html += ("""</table>""")
        return html

    @cherrypy.expose    
    def search(self, q):
        img = plt.imread(os.path.join(IMAGE_DIR, q))
        term_vec = image_search_utils.get_vector_rgb(img)
        results = image_search_utils.search(term_vec, self.es, self.index_name, self.doc_type)
        html = """<h3>Query:</h3><br/>"""
        html += """<b>Filename:</b> %s<br/>""" % (q)
        html += """<b>Image:</b><br/>"""
        html += ("""<img src="images/%s" height="75" width="100"/><br/><hr/>"""
            % (q))
        html += """<h3>Results</h3><hr/>"""
        html += """<table cellspacing="0" cellpadding="0" border="1" width="100%">"""
        html += """<tr><th>Rank</th><th>Filename</th><th>Image</th><th>Score</th></tr>"""
        rank = 1
        for result in results:
            html += """<tr valign="top">"""
            html += """<td align="center">%d</td>""" % (rank)
            html += """<td align="center">%s</td>""" % (result[0])
            html += """<td align="center"><img src="images/%s" height="75" width="100"/></td>""" % (result[0])
            html += """<td align="center">%.5f</td>""" % (result[1])
            html += """</tr>"""
            rank += 1
        html += """</table>"""
        return html
    
if __name__ == "__main__":
    cherrypy.config.update({
        "server.socket_host": "127.0.0.1",
        "server.socket_port": 8080
    })
    conf = {
        "/images": {
            "tools.staticdir.on": True,
            "tools.staticdir.dir": IMAGE_DIR
        }
    }
    cherrypy.quickstart(ImageSearchService(), config=conf)

The screenshots below show the search query page (also the home page) and a results page. Notice that the query image and first result image on the results page are identical. This is true for most searches I ran, although in a few cases the query result appeared in the second position, and in fewer cases, at lower positions.






In order to figure out how good the search was overall, I ran an evaluation to measure the change in Mean Reciprocal Rank (MRR), for a random set of 50 images, as I varied the number of query image features. Here is the code to do the evaluation.

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
# -*- coding: utf-8 -*-
# Source: eval_feats_rgb.py
from __future__ import division, print_function
import elasticsearch
import matplotlib.pyplot as plt
import numpy as np
import os

import image_search_utils

IMAGE_DIR = "../data/butterflies"
ES_HOST = "127.0.0.1"
ES_PORT = "9200"
ES_INDEXNAME = "butterflies"
ES_DOCTYPE = "rgb"

# gather 50 random unique images from collection
np.random.seed(42)
test_image_ids = set(np.random.choice(200, 50, replace=False).tolist())
test_images = []
curr_idx = 0
for fname in os.listdir(IMAGE_DIR):
    if curr_idx in test_image_ids:
        test_images.append(fname)
    curr_idx += 1

es = elasticsearch.Elasticsearch(hosts = [{
    "host": ES_HOST,
    "port": ES_PORT
}])
mrrs = []
top_ns = range(0, 80, 5)
top_ns[0] = 1
for top_n in top_ns:
    print("Now running with top %d features..." % (top_n))
    mrr = 0.0
    for test_image in test_images:
        print("... querying with %s" % (test_image))
        img = plt.imread(os.path.join(IMAGE_DIR, test_image))
        img_vec = image_search_utils.get_vector_rgb(img)
        results = image_search_utils.search(img_vec, es, ES_INDEXNAME, 
                                            ES_DOCTYPE, top_n)
        result_images = [result[0] for result in results]                                            
        result_mrr = 0.1        
        for rank in range(len(result_images)):
            if result_images[rank] == test_image:
                result_mrr = 1.0 / (rank + 1)
                break
        mrr += result_mrr
    mrrs.append(mrr /  len(test_images))

plt.plot(top_ns, mrrs)
plt.xlabel("Number of features (Top N)")
plt.ylabel("Mean Reciprocal Rank (MRR)")
plt.grid(True)
plt.show()        

The output of this code is the chart below, which shows that we get the best MRR overall with around 35 features, and plateues thereafter. The MRR at the platueue is around 0.8, so most of our query images are returned within the top 2 ositions in the search results, which I think is quite good for such a simple model.


The flip side of this analysis is to see how the scores decrease by position. Understanding this behavior would enable us to set a threshold for "good" matches. Keeping the top_N parameter set to 35, we run a small subset of images and chart their scores by position. The code to do this 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
# -*- coding: utf-8 -*-
# Source: eval_scores_rgb.py
from __future__ import division, print_function
import elasticsearch
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import os

import image_search_utils

IMAGE_DIR = "../data/butterflies"
ES_HOST = "127.0.0.1"
ES_PORT = "9200"
ES_INDEXNAME = "butterflies"
ES_DOCTYPE = "rgb"
NUM_TESTS = 5

# gather 5 random unique images from collection
np.random.seed(42)
test_image_ids = set(np.random.choice(200, NUM_TESTS, replace=False).tolist())
test_images = []
curr_idx = 0
for fname in os.listdir(IMAGE_DIR):
    if curr_idx in test_image_ids:
        test_images.append(fname)
    curr_idx += 1

es = elasticsearch.Elasticsearch(hosts = [{
    "host": ES_HOST,
    "port": ES_PORT
}])

cmap = matplotlib.cm.get_cmap("Spectral")
color = np.linspace(0, 1, NUM_TESTS)

for i in range(NUM_TESTS):
    img = plt.imread(os.path.join(IMAGE_DIR, test_images[i]))
    img_vec = image_search_utils.get_vector_rgb(img)
    results = image_search_utils.search(img_vec, es, ES_INDEXNAME, ES_DOCTYPE,
                                        top_n=35, start=0, size=50)
    scores = [result[1] for result in results]
    plt.plot(range(len(scores)+1)[1:], scores, color=cmap(color[i]))   
plt.axhline(0.9, 0, 50, color='r', linewidth=1.5, linestyle="dotted")
plt.xlabel("Result Position (Rank)")
plt.ylabel("Score")
plt.grid(True)
plt.show()

And the chart generated by the above program is as follows. As can be seen, all the queries result in 5-10 images above the 0.9 mark (marked by the red dotted line), which might be a good threshold to use in this case if we were trying to find very similar images.


Thats all I have for today. In this post, I described about how ES handles Payloads and function queries. Looking back with the work I did with Solr payloads, I found the ES implementation quite intuitive and easy to use. I was also able to build an index where I store pre-built vectors (as opposed to have the index create vectors out of the text) and compute cosine similarities using function queries. I am actually quite pleasantly surprised at how well the functionality is working. In coming weeks, I plan on making some improvements, will have more to share once I do that.

Saturday, April 18, 2015

Scoring token distance in Lucene sloppy queries


Lucene (and Lucene based search platforms such as Solr and ElasticSearch) has the concept of slop for inexact phrase queries, which specifies how loose the phrase matching can be. For example, "four seven" would not match a document containing the Gettysburg Address, but "four seven"~2 or "seven four"~3 would. Here the slop is the number of token transpositions it would take for the query string to match the target string.

This was all I knew about slop for a long time and I never had a need to know beyond this. Recently, however, I was working with some people whose had a background in other search engines, and they raised the question, that given two documents like this:

  • Four of five fathers don't know..."
  • Four score and seven years ago, our fathers brought forth on this continent...

and a query "four fathers"~20, which one of these would score higher and by how much? My understanding was that slop is merely a filtering mechanism and both results would show up in no particular order. Turns out this is not quite true - while slop does serve to filter the results, the distance between the tokens does influence the final scores. I would have chalked it up to ignorance on my part and moved on, but a quick poll among some of my Lucene programmer colleagues revealed that this is something most people hadn't really thought about, so I thought it may be useful to write about it. Hence this post.

In order to investigate the variation of score with token distance, I set up the following simple experiment. I built a set of "documents", each with exactly 42 1-character "words" consisting of the letters a-j repeated 4 times. The first word in each document was x and another character y was put into the second, third, fourth, etc token positions. So my "documents" looked something like this:

1
2
3
4
x y a b c d ...
x a y b c d ...
x a b y c d ...
...

To build this corpus of documents, I wrote this little Python script to build an array of JSON documents like so:

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import string

first_10 = string.lowercase[0:10]
doc_tpl = first_10[0:1] + 'x' + first_10[1:] + first_10*3
doc_id = 0

print("[")
for i in range(len(doc_tpl)-2):
   doc = doc_tpl[0:i+2] + 'y' + doc_tpl[i+2:]

   doc_title = " ".join([c for c in doc])
   print("{\"id\":\"%s\", \"title\":\"%s\"}," % (doc_id, doc_title))
   doc_id = doc_id + 1
print("]")

This is sent to Solr's update handler using a curl command like so:

1
2
curl "http://localhost:8983/solr/update/json?commit=true" \
    -H "Content-type:application/json" --data-binary @test_data.json

We then send a single sloppy query "x y"~20 to Solr and graph the resulting scores. I then use Scipy's curve_fit command to fit the points to a formula I derived by looking at the explain output and the code (described after the graph).

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
from __future__ import division
import urllib2
import urllib
import json
import matplotlib.pyplot as plt
import numpy as np
from scipy.optimize import curve_fit
import math

def proximity_score(x, a, b):
  return a + b / np.power(x + 1, 0.5)

url = "http://localhost:8983/solr/collection1/select"

urlparams = {
  "q": "*:*",
  "wt": "json",
  "rows": "0",
  "fl": "id,score"
}
conn = urllib2.urlopen(url + "?" + urllib.urlencode(urlparams))
response = json.load(conn)
count = response["response"]["numFound"]
conn.close()
urlparams["q"] = "\"x y\"~20"
urlparams["rows"] = str(count)
conn = urllib2.urlopen(url + "?" + urllib.urlencode(urlparams))
response = json.load(conn)
xs = []
ys = []
for doc in response["response"]["docs"]:
  xs.append(int(doc["id"]))
  ys.append(float(doc["score"]))
conn.close()

axs = np.array(xs)
ays = np.array(ys)
plt.plot(axs, ays, 'bo', label="Actual")

popt, pcov = curve_fit(proximity_score, axs, ays)
print popt
plt.plot(axs, proximity_score(axs, *popt), 'r--', linewidth=1.5, label="Fitted")

plt.xlabel("Distance between tokens")
plt.ylabel("Score")
plt.legend()
plt.show()

This gives us the chart shown below. The blue points are the actual scores returned for each record with the specified token distances between the tokens x and y, and the red dotted line is the curve given by:

1
score = 9.96e-10 + (0.25 / math.sqrt(dist + 1))

Here the intercept term is almost zero, and the 0.25 in the numerator is an artifact of the other parameters in the experiment. The takeaway is that scores vary as the inverse of the square root of the between token distance for sloppy queries, all other things being constant.


I had initially tried to plot an exponential curve, but it didn't match as well as this one. So I started looking at the explain output for clues. Here is the explain for the top 3 results, formatted for readbility.

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
0: 0.24367055 = 
   (MATCH) weight(text:"x y"~20 in 0) [DefaultSimilarity], result of:
     0.24367055 = fieldWeight in 0, product of:
       1.0 = tf(freq=1.0), with freq of:
         1.0 = phraseFreq=1.0
       1.9493644 = idf(), sum of:      
         0.9746822 = idf(docFreq=39, maxDocs=39)
         0.9746822 = idf(docFreq=39, maxDocs=39)
       0.125 = fieldNorm(doc=0),
1: 0.1723011 = 
   (MATCH) weight(text:"x y"~20 in 1) [DefaultSimilarity], result of:
     0.1723011 = fieldWeight in 1, product of:
       0.70710677 = tf(freq=0.5), with freq of:
         0.5 = phraseFreq=0.5    
       1.9493644 = idf(), sum of:
         0.9746822 = idf(docFreq=39, maxDocs=39)
         0.9746822 = idf(docFreq=39, maxDocs=39)
       0.125 = fieldNorm(doc=1),
2: 0.14068326 = 
   (MATCH) weight(text:"x y"~20 in 2) [DefaultSimilarity], result of:
     0.14068326 = fieldWeight in 2, product of:
       0.57735026 = tf(freq=0.33333334), with freq of:
         0.33333334 = phraseFreq=0.33333334
       1.9493644 = idf(), sum of:
         0.9746822 = idf(docFreq=39, maxDocs=39)
         0.9746822 = idf(docFreq=39, maxDocs=39)
       0.125 = fieldNorm(doc=2)\n",
...

On inspecting the output, I realized that the variation was happing in the phraseFreq line, falling off as (1, 0.5, 0.33, ...), ie by the inverse of the distance. Looking at the code for DefaultSimilarity, it looks like phraseFreq contributes the square root of itself plus 1 to the score via tf(phraseFreq) in the line immediately above.

This is all I have for this week. Hopefully I was able to share something you didn't know through this experiment.

Saturday, December 14, 2013

Using Lucene Similarity in Item-Item Recommenders


Last week, I implemented 4 (of 5) recommenders from the Programming Assignments of the Introduction to Recommender Systems course on Coursera, but using Apache Mahout and Scala instead of Lenskit and Java. This week, I implement an Item Item Collaborative Filtering Recommender that uses Lucene (more specifically, Lucene's More Like This query) as the item similarity provider.

By default, Lucene stores document vectors keyed by terms, but can be configured to store term vectors by setting the field attribute TermVector.YES. In case of text documents, words (or terms) are the features which are used to compute similarity between documents. I am using the same dataset as last week, where movies (items) correspond to documents and movie tags correspond to the words. So we build a movie "document" by preprocessing the tags to form individual tokens and concatenating them into a tags field in the index.

Three scenarios are covered. The first two are similar to the scenarios covered with the item-item collaborative filtering recommender from last week, where the user is on a movie page, and we need to (a) predict the rating a user would given a specified movie and (b) find movies similar to a given movie. The third scenario is recommending movies to a given user. We describe each algorithm briefly, and how Lucene fits in.

Find Movies Similar to given Movie: This is just content based filtering and is implemented as a simple MLT query. Given the itemID, we lookup the docID of the source movie, then get the top N movies that are most like it. We then return a List of tuples of docIDs that are similar and their similarities (scores), except the original docID.

Predict a User's Rating for a Movie: This is the prediction functionality of an item-item CF recommender. The prediction is based on how the user has rated other movies similar to this one. Otherwise, we calculate the average weighted sum of the ratings of already rated items, where the weights are the similarities between the target item and this item. If the movie is already rated, we just return the rating. Similarity between two items are calculated using the MLT query using a simplifying assumption - a target item outside the item neighborhood has 0 similarity with the source item. If we did not use this assumption, we would have to use approaches such as the TermFreqVector API for Lucene 3.x or the Fields API for Lucene 4.x to compute individual doc-doc similarities.

Recommend Movies to a User: This is topN recommender functionality of an item-item CF. We recommend movies that are similar to ones the user has already rated, weighted by the similarity between this item and the rated item. We use the algorithm outlined in Mahout in Action, § 4.4.1, detailed below. The 3rd and 4th lines in the algorithm is essentially the rating prediction task we described above. Essentially, we calculate the prediction for all items not rated so far by the user, and return them sorted by descending order of predicted rating.

1
2
3
4
5
    for item i that u has no preference for yet
      for every item j that u has a preference for
        compute a similarity s between i and j
        add u's preference for j, weighted by s, to a running average
    return the top items, ranked by weighted avera

Code


Here is the code for the Lucene based Item-Item Collaborative Filtering Recommender. The openIndex() method builds the Lucene index off the ratings.csv file if the index does not already exist, creating two fields - the docID, which is a keyword field, and tags, which is a whitespace tokenized text field with the TermVector attribute set to YES so MLT can work on it. The rest of the code just implements the algorithms explained above.

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
// Source: src/main/scala/com/mycompany/mia/recsys/LuceneIICFRecommender.scala
package com.mycompany.mia.recsys

import java.io.File
import java.io.StringReader

import scala.Array.canBuildFrom
import scala.collection.JavaConversions.asScalaIterator
import scala.collection.JavaConversions.iterableAsScalaIterable
import scala.collection.mutable.ArrayBuffer
import scala.io.Source

import org.apache.lucene.analysis.core.WhitespaceAnalyzer
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.DirectoryReader
import org.apache.lucene.index.IndexReader
import org.apache.lucene.index.IndexWriter
import org.apache.lucene.index.IndexWriterConfig
import org.apache.lucene.index.Term
import org.apache.lucene.queries.mlt.MoreLikeThis
import org.apache.lucene.search.IndexSearcher
import org.apache.lucene.search.TermQuery
import org.apache.lucene.store.SimpleFSDirectory
import org.apache.lucene.util.Version
import org.apache.mahout.cf.taste.impl.model.file.FileDataModel

/**
 * TopN Item Item Collaborative Filtering Recommender that
 * uses Lucene as its source for Item-Item Similarity.
 */
class LuceneIICFRecommender(
    modelfile: File, tagfile: File, indexdir: File) {

  val model = new FileDataModel(modelfile)

  val analyzer = new WhitespaceAnalyzer(Version.LUCENE_43)
  val indexReader = openIndex(tagfile, indexdir)
  val indexSearcher = new IndexSearcher(indexReader)

  /**
   * Given a user, return the topN items that the user
   * may be interested in. Do not include items user has
   * rated already.
   * @param user the userID
   * @param topN the number of items to recommend.
   * @return a List of Pairs of itemID and similarity score.
   */
  def recommend(user: Long, topN: Int): List[(Long,Double)] = {
    model.getItemIDs()
      .map(item => (item.toLong, predict(user, item))) 
      .filter(p => p._2 > 0.0D)
      .toList
      .sortWith((a,b) => a._2 > b._2)
      .slice(0, topN)
  }

  /**
   * Predict the rating that a user would give an item.
   * If the user has already rated the item, we return
   * the actual rating.
   * @param user the user ID.
   * @param item the item ID.
   * @return the predicted rating the user would rate 
   *         the item.
   */
  def predict(user: Long, item: Long): Double = {
    val ratedItems = getRatedItems(user)
    if (ratedItems.contains(item)) 
      model.getPreferenceValue(user, item).toDouble
    else {
      val nds = ratedItems.map(j => {
        val simIJ = similarity(item, j, 20)
//        val simIJ = similarity(item, j, this.cosine(_, _))
//        val simIJ = similarity(item, j, this.tanimoto(_, _))
        val rUJ = model.getPreferenceValue(user, j)
        (simIJ * rUJ, simIJ)
      })
      val numer = nds.map(_._1).foldLeft(0.0D)(_ + _)
      val denom = nds.map(_._2).foldLeft(0.0D)(_ + _)
      numer / denom
    }
  }

  /**
   * Return a set of items that have been rated by
   * this user.
   * @param user the user ID.
   * @return Set of items not yet rated by this user.
   */
  def getRatedItems(user: Long): Set[Long] = {
    model.getPreferencesFromUser(user)
      .map(pref => pref.getItemID())
      .toSet
  }

  /**
   * Returns the similarity between two items, limited
   * to the specified neighborhood size. If item is too
   * dissimilar (ie out of the specified item neighborhood
   * size) then the similarity returned is 0.0.
   * @param itemI the item ID for the i-th item.
   * @param itemJ the item ID for the j-th item.
   * @param nnbrs item neighborhood size
   * @return similarity between itemI and itemJ.
   */
  def similarity(itemI: Long, itemJ: Long, nnbrs: Int): Double = {
    val simItemScores = similarItems(itemI, nnbrs)
      .filter(itemScore => itemScore._2 > 0.0D)
      .toMap
    simItemScores.getOrElse(itemJ, 0.0D)
  }
  
  /**
   * Find a neighborhood of items of size nnbrs which are most
   * similar to the item specified. Uses Lucene MoreLikeThis
   * query to calculate the similarity.
   * @param item the source item.
   * @param nnbrs the neighborhood size.
   * @return a List of (item ID, similarity) tuples representing
   *         the item neighborhood.
   */
  def similarItems(item: Long, nnbrs: Int): List[(Long,Double)] = {
    val docID = getFromIndex(item)
    if (docID < 0) List()
    else {
      val mlt = new MoreLikeThis(indexReader)
      mlt.setMinTermFreq(0)
      mlt.setMinDocFreq(0)
      mlt.setFieldNames(Array[String]("tags"))
      mlt.setAnalyzer(analyzer)
      val doc = indexReader.document(docID)
      val tags = doc.getValues("tags").mkString(" ")
      val mltq = mlt.like(new StringReader(tags), null)
      val rhits = indexSearcher.search(mltq, nnbrs + 1).scoreDocs
      rhits.map(rhit => {
        val rdoc = indexReader.document(rhit.doc)
        (rdoc.get("itemID").toLong, rhit.score.toDouble)
      })
      .toList
      .filter(docsim => docsim._1 != item)
    }
  }

  /**
   * Calculate similarity between two items specified
   * by item ID using the specified similarity function.
   * @param itemI the item ID for the first item.
   * @param itemJ the item ID for the second item.
   * @param simfunc the similarity function to use.
   * @return the similarity between itemI and itemJ.
   */
  def similarity(itemI: Long, itemJ: Long, 
      simfunc: (Map[String,Long], Map[String,Long]) => Double): 
      Double = {
    simfunc.apply(termVector(itemI), termVector(itemJ))
  }
  
  /**
   * Extract the term vector for an item as a sparse
   * map of tags to raw tag frequencies.
   * @param item the item ID
   * @return the term vector for the item.
   */
  def termVector(item: Long): Map[String,Long] = {
    val docID = getFromIndex(item)
    val terms = indexReader.getTermVector(docID, "tags")
    val termsEnum = terms.iterator(null)
    Stream.continually(termsEnum.next())
      .takeWhile(term => term != null)
      .map(term => (term.utf8ToString(), termsEnum.totalTermFreq()))
      .toMap
  }

  
  /**
   * Implementation of cosine similarity using Maps.
   * @param vecA Map representation of sparse vector
   *             for itemA
   * @param vecB Map representation of sparse vector
   *             for itemB.
   * @return the cosine similarity between vecA and
   *             vecB (normalized by Euclidean norm).
   */
  def cosine(vecA: Map[String,Long], 
      vecB: Map[String,Long]): Double = {
    val dotProduct = vecA.keySet.intersect(vecB.keySet)
      .map(key => vecA(key) * vecB(key))
      .foldLeft(0.0D)(_ + _)
    val normA = scala.math.sqrt(vecA.values
      .map(v => scala.math.pow(v, 2.0D))
      .foldLeft(0.0D)(_ + _))
    val normB = scala.math.sqrt(vecB.values
      .map(v => scala.math.pow(v, 2.0D))
      .foldLeft(0.0D)(_ + _))
    dotProduct / (normA * normB)
  }
  
  /**
   * Implementation of Tanimoto coefficient using Maps.
   * @param vecA Map representation of sparse vector
   *             for itemA
   * @param vecB Map representation of sparse vector
   *             for itemB.
   * @return the Tanimoto coefficient between vecA and
   *             vecB.
   */
  def tanimoto(vecA: Map[String,Long], 
      vecB: Map[String,Long]): Double = {
    val num = vecA.keySet.intersect(vecB.keySet).size.toDouble
    val den = vecA.keySet.union(vecB.keySet).size.toDouble
    num / den
  }

  /**
   * Convenience method to get a docID from the Lucene
   * index by item ID.
   * @param the itemID for the item.
   * @return the corresponding docID from Lucene.
   */
  def getFromIndex(item: Long): Int = {
    val hits = indexSearcher.search(
      new TermQuery(new Term("itemID", item.toString)), 1)
    if (hits.totalHits == 0) -1 else hits.scoreDocs(0).doc 
  }

  /**
   * Create a Lucene index from the movie tags file if it does
   * not exist already, then return a handle to the IndexReader.
   * @param tagfile the File representing the movie-tags.csv
   * @param indexdir the Lucene index directory.
   * @return the reference to the IndexReader.
   */
  def openIndex(tagfile: File, indexdir: File): IndexReader = {
    if (! indexdir.exists()) {
      // build index from data
      indexdir.mkdirs();
      val iwconf = new IndexWriterConfig(Version.LUCENE_43, 
        analyzer)
      iwconf.setOpenMode(IndexWriterConfig.OpenMode.CREATE)
      val indexWriter = new IndexWriter(
        new SimpleFSDirectory(indexdir), iwconf)
      var prevItemID = -1L
      var tagBuf = ArrayBuffer[String]()
      Source.fromFile(tagfile)
        .getLines()
        .foreach(line => {
           val Array(itemID, tag) = line.split(",")
           if (itemID.toInt == prevItemID || prevItemID < 0L) {
             tagBuf += tag.replaceAll(" ", "_").toLowerCase()
           } else {
             val doc = new Document()
             doc.add(new Field("itemID", prevItemID.toString, 
               Store.YES, Index.NOT_ANALYZED))
             doc.add(new Field("tags", tagBuf.mkString(" "), 
               Store.YES, Index.ANALYZED, TermVector.YES))
             indexWriter.addDocument(doc)
             tagBuf.clear
             tagBuf += tag.replaceAll(" ", "_").toLowerCase()
           }
           prevItemID = itemID.toInt
        })
      val doc = new Document()
      doc.add(new Field("itemID", prevItemID.toString, 
        Store.YES, Index.NOT_ANALYZED))
      doc.add(new Field("tags", tagBuf.mkString(" "), 
        Store.YES, Index.ANALYZED, TermVector.YES))
      indexWriter.addDocument(doc)
      indexWriter.commit()
      indexWriter.close()
    }
    DirectoryReader.open(new SimpleFSDirectory(indexdir))
  }
}

Unit Test


I don't have test results to test my implementation, so I just ran some cases to make sure the results looked believable. The JUnit test below does some very basic tests and dumps out results for human validation.

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
// Source: src/test/scala/com/mycompany/mia/recsys/LuceneIICFRecommenderTest.scala
package com.mycompany.mia.recsys

import java.io.File

import scala.io.Source

import org.junit.Assert
import org.junit.Test

class LuceneIICFRecommenderTest {

  val liicf = new LuceneIICFRecommender(
    new File("data/recsys/ratings.csv"), 
    new File("data/recsys/movie-tags.csv"),
    new File("data/recsys/itemindex"))
  val titles = new File("data/recsys/movie-titles.csv")
  val movieNames = Source.fromFile(titles)
    .getLines()
    .map(line => {
      val Array(movieID, title) = line.split(",")
      (movieID.toLong, title)
    }).toMap
    
  @Test def testRecommendItemsGivenUser(): Unit = {
    val scores = liicf.recommend(15L, 20)
    Assert.assertEquals(scores.size, 20)
    Console.println("Recommendations for user(15)")
    scores.foreach(score => {
      Console.println("%5.3f %5d %s".format(
        score._2, score._1, movieNames(score._1)))
    })
  }
  
  @Test def testRecommendItemsGivenItem(): Unit = {
    val recommendedItems = liicf.similarItems(77L, 10)
    Assert.assertEquals(recommendedItems.size, 10)
    Console.println("recommendations for movie(%d): %s"
      .format(77L, movieNames(77L)))
    recommendedItems.foreach(docsim => {
      Console.println("%7.4f %5d %s"
        .format(docsim._2, docsim._1, movieNames(docsim._1)))
    })
  }

  @Test def testPredictRatingForItem(): Unit = {
    val predictedRating = liicf.predict(2048L, 393L)
    Console.println("prediction(2048,393) = " + predictedRating)
    Assert.assertEquals(predictedRating, 3.69D, 0.01D)
    val predictRatingForRatedItem = liicf.predict(2048L, 77L)
    Console.println("prediction(2048,77) = " + predictRatingForRatedItem)
    Assert.assertEquals(predictRatingForRatedItem, 4.5D, 0.1D)
  }
}

Results


And finally, the results of the 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
Recommendations for user(15)
5.000    98 Gladiator (2000)
5.000   120 The Lord of the Rings: The Fellowship of the Ring (2001)
5.000   122 The Lord of the Rings: The Return of the King (2003)
5.000   180 Minority Report (2002)
5.000   280 Terminator 2: Judgment Day (1991)
5.000   603 The Matrix (1999)
5.000   604 The Matrix Reloaded (2003)
5.000   640 Catch Me If You Can (2002)
5.000   808 Shrek (2001)
5.000  1891 Star Wars: Episode V - The Empire Strikes Back (1980)
5.000  2502 The Bourne Supremacy (2004)
4.674   146 Crouching Tiger Hidden Dragon (Wo hu cang long) (2000)
4.634    11 Star Wars: Episode IV - A New Hope (1977)
4.634  1637 Speed (1994)
4.613  5503 The Fugitive (1993)
4.570  1894 Star Wars: Episode II - Attack of the Clones (2002)
4.549    24 Kill Bill: Vol. 1 (2003)
4.510   955 Mission: Impossible II (2000)
4.500    13 Forrest Gump (1994)
4.500    85 Raiders of the Lost Ark (1981)

recommendations for movie(77): Memento (2000)
 0.3967   141 Donnie Darko (2001)
 0.3199    38 Eternal Sunshine of the Spotless Mind (2004)
 0.2358   629 The Usual Suspects (1995)
 0.2260   807 Seven (a.k.a. Se7en) (1995)
 0.1976   550 Fight Club (1999)
 0.1609   745 The Sixth Sense (1999)
 0.1079    63 Twelve Monkeys (a.k.a. 12 Monkeys) (1995)
 0.0841   680 Pulp Fiction (1994)
 0.0609   393 Kill Bill: Vol. 2 (2004)
 0.0585   274 The Silence of the Lambs (1991)

prediction(2048,393) = 3.691436473173808
prediction(2048,77) = 4.5

I considered using Lucene's Field API to compute document vectors and using Apache Mahout vectors to compute the cosine similarity instead of using the neighborhood approximation so I could use MLT, but then it wouldn't have been any different from the DataModel approach, so I stuck with using MLT. Variants of this approach can also be used to build user-user CF recommenders as well.

The code for this post is available at my mia-scala-examples on my GitHub page. The Source comment at the start of each code block indicates the location.

Update 2013-12-18: Based on Ravi's comment below about using the Tanimoto Coefficient, I started to rethink my approach of sticking to MLT for Lucene (convenient but suddenly too restrictive :-)). If we can model our item as a bag of features (bag of words for documents, bag of tags for movies in our example, etc), then Lucene's Fields API can provide an alternative approach to composing term vectors for each document. I have created a termVector method that does this, and a new overloaded similarity method which takes the two item IDs and a similarity function reference and calculates a similarity, as well as the Cosine Similarity and Tanimoto Coefficient functions. Using this, I was able to get results for these two similarity metrics (by switching around the computation for simIJ in lines 77-79 in the main code, which are shown below. In this case, the ordering seems to be fairly consistent across various similarity computations.

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
==== Cosine Similarity (without neighborhood cliff) ====

Recommendations for user(15)
5.000    98 Gladiator (2000)
5.000   120 The Lord of the Rings: The Fellowship of the Ring (2001)
5.000   122 The Lord of the Rings: The Return of the King (2003)
5.000   180 Minority Report (2002)
5.000   280 Terminator 2: Judgment Day (1991)
5.000   603 The Matrix (1999)
5.000   604 The Matrix Reloaded (2003)
5.000   640 Catch Me If You Can (2002)
5.000   808 Shrek (2001)
5.000  1891 Star Wars: Episode V - The Empire Strikes Back (1980)
5.000  2502 The Bourne Supremacy (2004)
4.500    13 Forrest Gump (1994)
4.500    85 Raiders of the Lost Ark (1981)
4.500  1892 Star Wars: Episode VI - Return of the Jedi (1983)
4.500  2164 Stargate (1994)
4.500  2501 The Bourne Identity (2002)
4.500 36955 True Lies (1994)
4.417   954 Mission: Impossible (1996)
4.394  1637 Speed (1994)
4.389   955 Mission: Impossible II (2000)

recommendations for movie(77): Memento (2000)
 0.3967   141 Donnie Darko (2001)
 0.3199    38 Eternal Sunshine of the Spotless Mind (2004)
 0.2358   629 The Usual Suspects (1995)
 0.2260   807 Seven (a.k.a. Se7en) (1995)
 0.1976   550 Fight Club (1999)
 0.1609   745 The Sixth Sense (1999)
 0.1079    63 Twelve Monkeys (a.k.a. 12 Monkeys) (1995)
 0.0841   680 Pulp Fiction (1994)
 0.0609   393 Kill Bill: Vol. 2 (2004)
 0.0585   274 The Silence of the Lambs (1991)

prediction(2048,393) = 4.11945476427452
prediction(2048,77) = 4.5

==== Tanimoto Coefficient Similarity ====

Recommendations for user(15)
5.000    98 Gladiator (2000)
5.000   120 The Lord of the Rings: The Fellowship of the Ring (2001)
5.000   122 The Lord of the Rings: The Return of the King (2003)
5.000   180 Minority Report (2002)
5.000   280 Terminator 2: Judgment Day (1991)
5.000   603 The Matrix (1999)
5.000   604 The Matrix Reloaded (2003)
5.000   640 Catch Me If You Can (2002)
5.000   808 Shrek (2001)
5.000  1891 Star Wars: Episode V - The Empire Strikes Back (1980)
5.000  2502 The Bourne Supremacy (2004)
4.500    13 Forrest Gump (1994)
4.500    85 Raiders of the Lost Ark (1981)
4.500  1892 Star Wars: Episode VI - Return of the Jedi (1983)
4.500  2164 Stargate (1994)
4.500  2501 The Bourne Identity (2002)
4.500 36955 True Lies (1994)
4.288  1894 Star Wars: Episode II - Attack of the Clones (2002)
4.258   955 Mission: Impossible II (2000)
4.238  8358 Cast Away (2000)

recommendations for movie(77): Memento (2000)
 0.3967   141 Donnie Darko (2001)
 0.3199    38 Eternal Sunshine of the Spotless Mind (2004)
 0.2358   629 The Usual Suspects (1995)
 0.2260   807 Seven (a.k.a. Se7en) (1995)
 0.1976   550 Fight Club (1999)
 0.1609   745 The Sixth Sense (1999)
 0.1079    63 Twelve Monkeys (a.k.a. 12 Monkeys) (1995)
 0.0841   680 Pulp Fiction (1994)
 0.0609   393 Kill Bill: Vol. 2 (2004)
 0.0585   274 The Silence of the Lambs (1991)

prediction(2048,393) = 4.175819889778504
prediction(2048,77) = 4.5