Building A Recommendation System for Anime | by Anila Qureshi | Towards Data Science

Anime is a hand-drawn computer animation originating from Japan which has drawn a cult following around the world. The animation industry consists of more than 430 companies. Pokemon and Yugi-Oh are some of the most popular anime shows that have come to Western television. Spirited Away, a film created by Hayao Miyazaki and animated by Studio Ghibli, is the highest-grossing film within the anime genre. The reason it became so popular in the west is that a good friend of Miyazaki’s convinced him to sell distribution rights to Walt Disney. Like Spirited Away, there are thousands of really good anime films and shows produced by the same animation house. Many others can take this example and use it as a way to bring such works of art into Disney+ or any streaming site in the West. This brings me to what I’ve been working on recently: a recommendation system that can help anyone or any company to view/add the highest-rated anime. The Japan External Trade Organization has valued the industry’s overseas sales to 18 billion ( 5.2 billion for the US alone) in 2004. This has definitely grown and has the potential to grow even further, especially during this time in the world. Like some countries, Japan is facing one of the first long term recessions.
Below, you can see my step-by-step guide to a recommendation system for anime. This will help with the problem above and can create more in demand anime. It can also help anyone who is not familiar with the niche genre to quickly find top-rated items. (Helping to grow the market).
I have downloaded anime and user ratings from https://www.kaggle.com/CooperUnion/anime-recommendations-database . I will conduct exploratory data analysis to familiarise the reader and myself with the data presented. From there I have created a baseline model using Singular Value Decomposition(SVD). I will then do memory-based models that will look at user-based v item based. I will use KNNBase, KNNBaseline, and KNNWithMeans. Then I will take the top-performing model and evaluate its root mean squared error (rmse) and its mean absolute error (mae).
import pandas as pdimport numpy as npimport randomfrom random import randintimport matplotlib.pyplot as plt%matplotlib inlineimport seaborn as snsfrom scipy.sparse import csc_matrixfrom scipy.sparse.linalg import svdsfrom surprise.model_selection import train_test_splitfrom surprise.model_selection import GridSearchCVfrom surprise.model_selection import cross_validatefrom surprise.prediction_algorithms import KNNWithMeans, KNNBasic, KNNBaselinefrom surprise.prediction_algorithms import knnsfrom surprise.prediction_algorithms import SVDfrom surprise.similarities import cosine, msd, pearsonfrom surprise import accuracyfrom surprise import Readerfrom surprise import Dataset
For most of this project, I decided to stick with the above and it’s worked nicely. I have tried additional models below in google colab, I will add the necessary code for the import library there.
anime_df = pd.read_csv('./anime.csv')anime_df.head()
What does the shape look like? This is important because it helps with seeing any null values
anime_df.shape
Dropping nulls if they exist
anime_df.dropna(inplace=True)
Again we’ll check shape after the above command
anime_df.shape #this seemed to have reduced it down a bit
This has, in fact, reduced our anime data frame!
It’s important to show what each column represents when cleaning so that we have an idea of what can be scrubbed, transformed, or if we need to conduct some feature engineering.
anime_id: id number for each anime titlename: title of the motion picturegenre: categorytype: describes the anime into tv, movies, OVA, and 3 other categoriesepisodes: total number of episodes rating: -1–10, lowest to highestmembers: number of community members that are in this anime group
Next, do they match with the above?
anime_df.info() #having a look at all of the columns and types from the above cell and current to remove#any unneccessary extraneous data
Let’s look at the second csv file that was included on kaggle for this recommendation project.
rating_df = pd.read_csv('./rating.csv')rating_df.head()
Let’s see what they represent.
user_id: non-identifiable randomly generated user-idanime_id: the anime that the user has rated rating: rating out of 10 this user has assigned (-1 if the user watched it but didn’t assign a rating)
Checking the shape of ratings
rating_df.shape
They both have anime_id. Let’s join the two to make things a lot easier.
df_merge = pd.merge(anime_df, rating_df, on = 'anime_id')df_merge.head()
Let’s check the shape, again.
df_merge.shape
I learned the hard way that Singular Value Decomposition (SVD) is more sensitive to datasets this large. I am going to look at the minimum rating.
df_merge.rating_x.min()
I have decided to take out the columns with no ratings, that are represented as -1. When doing recommendation systems, it can depend on the person, assignment, and company to keep this in. I will likely go back in my spare time and add this back in and see about unfinished ratings and the effect on it has on recommendations. (It does play a huge role). I decided to take it out for pure amusement.
df_merge = df_merge[df_merge.rating_y != -1]df_merge.head()
Now I go in to check the shape again to see how much this has reduced my data for SVD.
df_merge.shape #have removed over 1 million rows
This still isn’t small enough to work efficiently for free google colab.
sample = df_merge.sample(frac=.25)sample.shape # this is still too large
I have run a lot of tests on my SVD (this took a day and a half and I have finally had to settle for the sample size below).
sample = df_merge.sample(n=5000)sample.shape #below I conduct SVD and it cannot handle anything larger than 5000 (i've tried)
Let’s see about the dtypes
sample.dtypes #rating_x needs to be an int, for it to work in ALS
Let’s convert them here
sample['rating_x'] = sample['rating_x'].astype(int)
Can check dtypes here. From the beginning, I have always found that checking up on my code is extremely important. It helps a lot with eliminating problems when there are debugging issues.
sample.dtypes
It seems that the rating dataframe is based on each user and their individual rating for each anime id, whereas the anime dataframe is an average overall rating from all of its viewers.
#we are going to look at how many times each rating appears in a columnratings_x = sample['rating_x'].value_counts() #continuousratings_y = sample['rating_y'].value_counts() #discreteprint(ratings_x)print(ratings_y)
Another thing that is super important for SVD is to make your variables discreet, otherwise, it’ll take up more of your time.
sample.rating_x = sample.rating_x.apply(round) sample.head()
Great, I think I’m done with scrubbing for now. Let’s move on to the fun part!
Distribution of User Ratings
# plot distribution in matplotlibratings_sorted = sorted(list(zip(ratings_y.index, ratings_y)))plt.bar([r[0] for r in ratings_sorted], [r[1] for r in ratings_sorted], color='cyan')plt.xlabel("Rating")plt.ylabel("# of Ratings")plt.title("Distribution of Ratings")plt.show()
I haven’t added the output to the cleaning, visuals are super important so I’ll add them in.
#number of usersprint("Number of Users:", df_merge.user_id.nunique()# print("Average Number of Reviews per User:", df_merge.shape[0])/df_merge.user_id.nunique()avg_rate_peruser = df_merge.shape[0]user = df_merge.user_id.nunique()avg_num_review_per_user = avg_rate_peruser/userprint("Average Number of Reveiws per User:", avg_num_review_per_user)
Number of Users: 15382
Average Number of Reviews per User: 88.69
sample[‘user_id’].value_counts()
The sample size of 5000, gives us a total of 3,381 users who have done reviews. The above is on the entire set.
Number of Reviews Per User
ratings_per_user = sample['user_id'].value_counts()ratings_per_user = sorted(list(zip(ratings_per_user.index, ratings_per_user)))plt.bar([r[0] for r in ratings_per_user], [r[1] for r in ratings_per_user], color='pink')plt.xlabel('User IDs')plt.ylabel('# of Reviews')plt.title('Number of Reviews per User')plt.show()
It seems that from our sample set, a lot of the scores given are 1s’ and 2–4s’.
Different Types of Anime
print("Number of users:", sample.user_id.nunique())print("Number of types of different anime:", sample.type.nunique())print("Types of type:", sample.type.value_counts())
From this, we can see there are 6 types. What are they?
6 Types of type:
TV 3492 Movie 666OVA 461Special 314 ONA 46 Music 21 Name: type, dtype: int64
OVA stands for original video animation
ONA stands for original net animation
Specials are one-off videos.
Music is themed around anime, and usually has an animation to go with it, but its typically a very short video.
Most Viewed Anime
# PLOT themfig = plt.figure(figsize=(12,10))sns.countplot(sample['type'], palette='gist_rainbow')plt.title("Most Viewed Anime", fontsize=20)plt.xlabel("Types", fontsize=20)plt.ylabel("Number of Views with Reviews", fontsize = 20)plt.legend(sample['type'])plt.show()
Let’s start implementing!
First thing’s first! Do a base model. I started with transforming my data.
#for surprise, it likes its data in a certain way and only that specific datadata = sample[['user_id', 'anime_id', 'rating_x']] #may need to do rating_x rounded and then use rating_yreader = Reader(line_format='user item rating', sep='')anime_loaded_data = Dataset.load_from_df(data, reader)#train_test_splittrainset, testset = train_test_split(anime_loaded_data, test_size=.2)
Make sure your data is in the right format
anime_loaded_data
Next, we’ll instantiate.
#INSTANTIATE the SVD and fit only the train setsvd = SVD()svd.fit(trainset)
Now to look at predictions and accuracy. This is important for comparing your learner models.
predictions = svd.test(testset) #accuracy.rmse(predictions)
This is my baseline: RMSE: 2.3128, 2.3127
Well, it’s definitely not between 0 and 1. These scores really depend on the domain. It’s definitely not perfect. My initial reaction was to freak out, but it turns out an RMSE over 1 is okay.
I would ideally like to see if I can reduce this.
#perform a gridsearch CVparams = {'n_factors': [20,50,100],'reg_all': [.02,.05, .10]}gridsearch_svd1 = GridSearchCV(SVD, param_grid=params, n_jobs=-1, joblib_verbose=3)gridsearch_svd1.fit(anime_loaded_data)print(gridsearch_svd1.best_score)print(gridsearch_svd1.best_params)
{‘rmse’: 2.3178, ‘mae’: 2.2080} {‘rmse’: {‘n_factors’: 20, ‘reg_all’: 0.02}, ‘mae’: {‘n_factors’: 20, ‘reg_all’: 0.02}}
It’s increased. I will now try other models particularly memory-based models followed by a content-based model.
K-Nearest Neighbors (KNN) Basic Algorithm
#cross validate with KNNBasicknn_basic = KNNBasic(sim_options={'name':'pearson', 'user_based':True}, verbose=True)cv_knn_basic = cross_validate(knn_basic, anime_loaded_data, n_jobs=2)for i in cv_knn_basic.items():print(i)print('-----------------')print(np.mean(cv_knn_basic['test_rmse']))
Will save you from the rest, here’s a short clip of the output
— — — — — — — — — 2.3178203641229667
You can do the same for the mean squared distance(msd).
knn_basic_msd = KNNBasic(sim_options = {'name': 'msd', 'user-based':True})cv_knn_basic_msd = cross_validate(knn_basic_msd, anime_loaded_data, n_jobs=2)for i in cv_knn_basic_msd.items():print(i)print('-----------------')print(np.mean(cv_knn_basic_msd['test_rmse']))
— — — — — — — — — 2.31787540672289, it scores higher. Let’s try another model
KNNBaseline
#cross validate with KNN Baseline (pearson)knn_baseline = KNNBaseline(sim_options={'name': 'pearson', 'user_based':True})cv_knn_baseline = cross_validate(knn_baseline, anime_loaded_data, n_jobs=3)for i in cv_knn_baseline.items():print(i)print('-----------------')print(np.mean(cv_knn_baseline['test_rmse']))
— — — — — — — — — 2.317895626569356
Again it’s increasing when we want it to decrease.
KNN Baseline with pearson_baseline
knn_pearson_baseline = KNNBaseline(sim_options={'name': 'pearson_baseline', 'user_based':True})cv_knn_pearson_baseline = cross_validate(knn_pearson_baseline, anime_loaded_data, n_jobs=3)for i in cv_knn_pearson_baseline.items():print(i)print('-------------------')print(np.mean(cv_knn_pearson_baseline['test_rmse']))
this gives us — — — — — — — — — -2.317801933128254
KNNWithMeans
knn_means = KNNWithMeans(sim_options={'name': 'pearson', 'user_based': True})cv_knn_means = cross_validate(knn_means, anime_loaded_data, n_jobs=3)for i in cv_knn_means.items():print(i)print('------------')print(np.mean(cv_knn_means['test_rmse']))
— — — — — — 2.3185632763331805
The SVD Baseline seems to have had the lowest RMSE. I will put this into a grid search again
param_grid = {'n_factors': [5, 20, 100],'n_epochs': [5,10],'lr_all': [.002, .005],'reg_all': [.02, .05, .5]}svd_gs = GridSearchCV(SVD, param_grid=param_grid, n_jobs=3, joblib_verbose=3)svd_gs.fit(anime_loaded_data)print(svd_gs.best_score)print(svd_gs.best_params)
Followed by
#Now use this to fit test set, initial gridsearch was 2.77096, so will use that gs herehighest_perf_algo = gridsearch_svd1.best_estimator['rmse']#retrain the whole settrainset = anime_loaded_data.build_full_trainset()highest_perf_algo.fit(trainset)#Biased Accuracy on trainsetpredictions = highest_perf_algo.test(trainset.build_testset())print('Biased accuracy on Trainset', end='')accuracy.rmse(predictions)#UnBiased Accuracy on testsetpredictions = highest_perf_algo.test(testset)print('Unbiased Accuracy on test', end='')accuracy.rmse(predictions)
and my results are: Biased accuracy on TrainsetRMSE: 2.3179, 2.317930111112067 and Unbiased Accuracy on testRMSE: 2.3203, 2.3203448019637083
I have done some collaborative models which are listed on my Github repo. My collaborative models take in cosine similarity and user-based v item based.
Content-Based
import nltkfrom sklearn.feature_extraction.text import TfidfVectorizerfrom nltk.corpus import stopwordsimport stringfrom nltk import word_tokenize, FreqDistimport refrom sklearn.decomposition import TruncatedSVDfrom sklearn.metrics.pairwise import linear_kernel
Next let’s extract the required fields.
genre_tag = sample.loc[:, ['anime_id','name','genre']]
Let’s see what it looks like
genre_tag.head()
The goal is to separate the words for each line.
tags = {}for col in ['genre']:  for a_id in sample['name'].unique():     for i in sample[sample['name'] == a_id][col]:     if a_id in tags:        tags[a_id].append(' '.join(i.lower().split('|')))     else:          tags[a_id] = i.lower().split('|')
Convert this into a list
tags_list = list(tags.values())
Look at the list
tags_list[:5]
Make sure to specify that the stopwords are in English
stopwords_list = stopwords.words('english')stopwords_list += list(string.punctuation)
The aim is to reduce the amount of noise that takes place.
def process_article(article):   article = ' '. join(article)tokens = word_tokenize(article)tokens_2 = []for token in tokens:   if token.lower() not in stopwords_list:      tokens_2.append(token.lower())return tokens_2
Let’s process the article. This will split the test into a list of strings that are separated based on spaces or apostrophes
processed_tags = list(map(process_article, tags_list))
Now let’s obtain the total vocabulary from genre.
articles_concat = []for doc in processed_tags:   articles_concat += doc
How many times does each word occur?
freqdist = FreqDist(articles_concat)freqdist
Let’s obtain the top 20 words, this will give a look into the most viewed genres
most_common_genre = freqdist.most_common(20)most_common_genre
Let’s plot it
plt.figure(figsize=(22,12))plt.bar([word[0] for word in most_common_genre], [word[1] for word in most_common_genre])plt.title("Most Popular Genre", fontsize= 20)plt.xlabel("Genre", fontsize=20)plt.ylabel("Total Number of Times Genre is Present in Anime Data", fontsize=20)plt.show()
Our recommendation system will likely recommend the comedy, action, romance, etc.
Now, convert it to a vectorizer
vectorizer = TfidfVectorizer(analyzer='word', ngram_range=(1,2), stop_words=stopwords_list)tags_list_2 = [‘ ‘.join(x) for x in tags_list]tf_idf_data_train = vectorizer.fit_transform(tags_list_2)
Next, let’s see how it measures up. Because NLTK is a completely different library, its best to look at its explained variance ratio.
#instantiate SVDsvd = TruncatedSVD(n_components=500, n_iter=7, random_state=42)#fit and transform the vectorized tf-idf matrixtf_idf_data_train_svd = svd.fit_transform(tf_idf_data_train)
Here we get the final result by:
print(svd.explained_variance_ratio_.sum())
0.9962558733287571
After trying a bunch of models, I feel that a content-based model is best.
In my notebook, I also look at calculating the similarities between anime with TF-IDF.
Based on the modeling done, it would be best to conduct more statistical analysis on top rated and viewed anime that comes from the same animation studios, writers, directors, and media houses. From here we can tweak the recommendation system to include their newest releases.
Another suggestion is to look at a time component analysis. TV shows are the most viewed type of anime, but during which time of the year? From here the recommendation system can help to fill in the gaps within the fiscal year for anime studios. They can either spread out the dates or work on a new pipeline for creating more specials.
References:
[1] A. Qureshi, Capstone (2020)
[2] Susan Li, Building and Testing Recommender Systems With Surprise, Step-By-Step (2018)
[3] Derrick Mwiti, How to build a Simple Recommender System in Python (2018)
[4] Robi56, Deep-Learning-For-Recommendation-Systems (2020)
[5] JJ, MAE and RMSE — Which Metric is Better? (2016)
[6] Wikipedia, Anime (2020)
[7] Wikipedia, Spirited Away (2020)
[8] Marie Mawad, Coronavirus: European Streaming Services are Booming Too (2020)