A recommender system for arXiv in < 500 lines of Python, and other lies I tell myself

For those who are more keen towards going in guns blazing, here is the repo link.

Now, onto the verbage.

My first (tiny) step towards making academic publishing better is actually developing a good understanding for the tools I want to use. So, I made my own hacked-together clone of ConnectedPapers, which takes a preprint from arXiv and recommends similar preprints to help researchers expedite literature review. The point of this blog post is namely to showcase that once you have a slightly-above-beginner-level grasp of the Python ecosystem, slapping together a data-driven service that solves a problem can be well within the scope of something a junior developer works on over the course of multiple weekends.

In some near future, I’ll find the time to put together a tool which basically allows you to upload a collection of citations, and then puts together a journal based on the topics extracted from the papers. But for now let’s focus on the basics: a recommendation engine for research papers.

Architecture Overview

It’s really amazing how easy it is to play Mr. Potato Head with Python libraries until you get just enough functionality to make something really cool. All in all, cloc tells me I’ve only written about 407 lines of code. Of course, that ignores the thousands of lines of dependencies I rely on. My point is that after a handful of blog posts, a couple hours of reading documentation and watching YouTube tutorials, I was able to cobble together a working version of this service in about 7 weekends. Anyways, here’s a simple diagram showing how all the parts of the backend talk come together to provide recommendations:

Indexr Backend

ETL: OAI2, and Parquet files

First things first, we need data. The purpose of this subsystem (located in the etl/ folder) is to accumulate all the metadata of all the arXiv preprints into a datastore which I can use throughout the rest of my code. Thankfully, arXiv offers all of their paper metadata in bulk via an OAI2 API endpoint which is documented here. This was attractive to me for the following reasons:

At a high level, here’s a step-by-step process required to produce the dataset I use for training models and providing search results at runtime:

  1. Run metha, collecting all the metadata for all papers in their default Dublin Core format. Since metha caches results, it’s easy to stay-up-to-date without generating loads of traffic.

  2. Decompress the XML files, clean up the metadata a little bit, and then save the data in Parquet format. This all happens in oai_dc_metadata.py.

  3. Accumulate the parquet files into a a giant pandas DataFrame in memory, and then generate categorical encodings for the tags used on each paper. This encoding is similar to a one-hot encoding, but instead of just having one bit set, it has N bits set, where N equals the number of tags for the paper. After the encodings are added the DataFrame, I save the “master” dataframe, and then I save a subset of the past five years’ worth of papers to a separate file as the dataset which I actually intend to offer in production. This all happens in gather_parquets.py

Since steps 2 and 3 form a neat map-reduce pattern, I’ve split them up into two files, the first which I can run in parallel using GNU parallel. To make deployment easier, I’ve cooked up a Docker container which does all of this by setting up a minimal environment and launching this bash script.

Where’s the Exploratory Data Analysis?

Before I go into the model training subsystem, I should clarify something. When I started this project, I had prior exposure to topic modeling due to prior exposure in an NLP class I took during my undergrad. Because I was so confident in the microscopic amount of knowledge I had on the topic, I went straight straight into building a model without doing any exploratory data analysis (EDA). In retrospect, this was a bad choice. Always do some EDA! A big reason why EDA is important is because it helps you uncover the unknown unknowns for not just your dataset, but also both your training pipeline and your inference pipeline. Here are some of the problems I still have yet to address, which effectively require me to do (better) EDA:

I justified skipping the work visualizing the dataset because I really wanted to get some end results quickly. While this is hopefully the first in a series of works I will do on automating the curation of scientific research, I was really craving the sense of having “made” something. I still think this wasn’t a bad rationale, but I probably would’ve also found some cool visualizations of my data to be both rewarding and cool additions to the blog post.

Model Training: Scikit + Gensim

The original draft of my training code was lifted from the Scikit-learn example on topic extraction, which you can see here. I had some prior experience with Latent Dirichlet Allocation in an academic setting, and my prior exposure to scikit-learn pushed me towards their API. Unfortunately, their LDA implementation is quite the memory hog and not the easiest to configure, so I’ve instead decided to set up LDA with GenSim. However, I’ve run into some hiccups there as well. I think I’ve just been a bit dense with the setup and I’ve forgotten to configure something obvious, but their example for logging model perplexity at training time isn’t working on my machine.

As a result of these shenanigans, I’m currently using NMF in the production build of my backend because it trains fast, doesn’t have a huge memory footprint, and mostly “Just Works”, even if the recommendations aren’t as nice as I’d like them to be.

Enough with the rambling, here’s a high level walkthrough of the training script as it works in production:

  1. Load the training data from the parquet file.
  2. Run the paper abstracts through a TfidfVectorizer, generating a doc-term matrix. Every row is a document, and every column is a term token in the vocabulary of our corpus. A row-column entry contains the TF-IDF score of that word in that document.
  3. Spin up an instance of the NMF class and then do a doc_topic_matrix = nmf_model.fit_transform(doc_term_matrix) , thus creating our document-topic matrix.
  4. Save all the matrices and the vectorizer to disk for reuse in the search runtime.

I’m keeping most of this discussion pretty high level, since there are already a fair amount of resources out there covering the concepts in detail. I quite like this, in particular section 6.2. It’s free and quite comprehensive. Furthermore, you really only need a surface-level understanding of these concepts to get this done, which is part of the magic of these libraries. If you have the ability to use your own eyes and brain to process natural language, then you too can read scikit-learn documentation to build your own basic NLP tools/services. Some patience and Python experience required.

Search Runtime: Putting the model and data together

Now that we have a model and data, we can start building search functionality exposed via REST API using FastAPI. For cleanliness reasons, I have all computation and data structures handled in the search_runtime.py and then the FastAPI wrapper is in its own backend.py module. This makes it easy to reuse the code/data I’ve done for the search runtime in testing/visualization scripts, which are now rapidly multiplying inside my indexr folder.

The search runtime module is decently commented, but I’d like to bring your attention to two key functions:

Both of these functions use cosine similarity for comparing vectors. This project opted for jaccard distance instead, and it makes more sense, but I’ve gotten the cosine distance code quite optimized, and the results aren’t terrible so I’m keeping it for now, until I come up with a way of doing fast, efficient Jaccard similarity for sparse matrices and dense vectors that doesn’t stick out like a sore thumb in my codebase.

What next?

So this is really just a rough draft for automating one of the first parts of academic publishing system: content curation. There are a couple semi-obvious ways I can improve upon what I have so far: