有人沉迷於刷抖音,有人沉迷於刷知乎,推薦系統現在已經影響甚至控制着人們的生活。本文將從最簡單的算法和流程入手,使用Flask和gorse快速搭建一個Steam遊戲推薦系統。html
在開始開發以前,咱們須要設計一下個人推薦系統的架構,以下圖所示:前端
能夠分割爲三個部分:python
這個Steam遊戲推薦系統已經部署到了steamlens.gorse.io,若是有Steam帳號以及可以訪問Steam社區的方法(你懂的),能夠嘗試一下它的個性化推薦效果。代碼也開源在了GitHub上,若是有可以訪問Steam社區服務器的VPS,那麼能夠嘗試本身部署。git
首先咱們須要安裝推薦系統後端gorse,若是已經安裝Go語言環境,將$GOBIN
加入環境變量$PATH
,那麼能夠直接使用如下命令安裝:github
$ go get github.com/zhenghaoz/gorse/...
複製代碼
一切的一切都基於數據,好在網上已經有別人共享的Steam數據集了,原數據量很是大,爲了方便演示使用,它被採樣到了games.csv。咱們建立一個文件夾,而後下載數據:算法
$ mkdir SteamLens
$ cd SteamLens
$ wget http://cdn.sine-x.com/backups/games.csv
...
$ head games.csv
76561197960272226,10,505
76561197960272226,20,0
76561197960272226,30,0
76561197960272226,40,0
76561197960272226,50,0
76561197960272226,60,0
76561197960272226,70,0
76561197960272226,130,0
76561197960272226,80,0
76561197960272226,100,0
複製代碼
能夠發現數據有三列,分別是用戶、遊戲和時長。sql
在建立推薦服務以前,須要選擇最適合的推薦算法,gorse提供來對各類模型進行評估,能夠運行gorse test -h
或者查看在線文檔學習如何使用。咱們的數據集屬於帶權(遊戲時長)隱式反饋,根據各個模型支持的輸入,可使用四種模型:item-pop
、knn_implicit
、bpr
和wrmf
。數據庫
首先測試一下非個性化推薦,做爲基準:json
$ gorse test item-pop --load-csv games.csv --csv-sep ',' --eval-precision --eval-recall --eval-ndcg --eval-map --eval-mrr
...
+--------------+----------+----------+----------+----------+----------+----------------------+
| | FOLD 1 | FOLD 2 | FOLD 3 | FOLD 4 | FOLD 5 | MEAN |
+--------------+----------+----------+----------+----------+----------+----------------------+
| Precision@10 | 0.080942 | 0.080655 | 0.080253 | 0.078880 | 0.078248 | 0.079796(±0.001548) |
| Recall@10 | 0.308894 | 0.310532 | 0.312299 | 0.305665 | 0.308428 | 0.309163(±0.003498) |
| NDCG@10 | 0.211919 | 0.209796 | 0.209004 | 0.209945 | 0.210466 | 0.210226(±0.001693) |
| MAP@10 | 0.133684 | 0.132018 | 0.130520 | 0.133500 | 0.135297 | 0.133004(±0.002484) |
| MRR@10 | 0.247601 | 0.242664 | 0.240176 | 0.244244 | 0.241920 | 0.243321(±0.004280) |
+--------------+----------+----------+----------+----------+----------+----------------------+
2019/11/07 09:56:51 Complete cross validation (22.037387763s)
複製代碼
測試一下隱式KNN:flask
$ gorse test knn_implicit --load-csv games.csv --csv-sep ',' --eval-precision --eval-recall --eval-ndcg --eval-map --eval-mrr
...
+--------------+----------+----------+----------+----------+----------+----------------------+
| | FOLD 1 | FOLD 2 | FOLD 3 | FOLD 4 | FOLD 5 | MEAN |
+--------------+----------+----------+----------+----------+----------+----------------------+
| Precision@10 | 0.150892 | 0.153211 | 0.147429 | 0.152162 | 0.150013 | 0.150742(±0.003312) |
| Recall@10 | 0.529160 | 0.546523 | 0.533619 | 0.543382 | 0.533702 | 0.537277(±0.009245) |
| NDCG@10 | 0.528442 | 0.546386 | 0.529590 | 0.545167 | 0.530433 | 0.536004(±0.010383) |
| MAP@10 | 0.451220 | 0.469989 | 0.453748 | 0.468641 | 0.453865 | 0.459493(±0.010497) |
| MRR@10 | 0.635610 | 0.656008 | 0.636238 | 0.658769 | 0.636045 | 0.644534(±0.014235) |
+--------------+----------+----------+----------+----------+----------+----------------------+
2019/11/07 09:59:14 Complete cross validation (1m4.169339752s)
複製代碼
再測試一下BPR:
$ gorse test bpr --load-csv games.csv --csv-sep ',' --eval-precision --eval-recall --eval-ndcg --eval-map --eval-mrr
...
+--------------+----------+----------+----------+----------+----------+----------------------+
| | FOLD 1 | FOLD 2 | FOLD 3 | FOLD 4 | FOLD 5 | MEAN |
+--------------+----------+----------+----------+----------+----------+----------------------+
| Precision@10 | 0.127123 | 0.128440 | 0.129396 | 0.124914 | 0.126719 | 0.127318(±0.002405) |
| Recall@10 | 0.502971 | 0.511863 | 0.515385 | 0.503914 | 0.505500 | 0.507926(±0.007458) |
| NDCG@10 | 0.434958 | 0.421336 | 0.427279 | 0.405582 | 0.424385 | 0.422708(±0.017126) |
| MAP@10 | 0.350960 | 0.332219 | 0.336659 | 0.313238 | 0.337824 | 0.334180(±0.020942) |
| MRR@10 | 0.495087 | 0.466407 | 0.477137 | 0.447885 | 0.475176 | 0.472338(±0.024453) |
+--------------+----------+----------+----------+----------+----------+----------------------+
2019/11/07 10:01:51 Complete cross validation (56.85278659s)
複製代碼
最後測試一下WRMF,由於遊戲時長的數值很是大,咱們須要設置一個小的權重係數:
$ gorse test wrmf --load-csv games.csv --csv-sep ',' --eval-precision --eval-recall --eval-ndcg --eval-map --eval-mrr --set-alpha 0.001
...
+--------------+----------+----------+----------+----------+----------+----------------------+
| | FOLD 1 | FOLD 2 | FOLD 3 | FOLD 4 | FOLD 5 | MEAN |
+--------------+----------+----------+----------+----------+----------+----------------------+
| Precision@10 | 0.145834 | 0.148021 | 0.147034 | 0.146564 | 0.143163 | 0.146123(±0.002960) |
| Recall@10 | 0.524673 | 0.533390 | 0.533113 | 0.535772 | 0.525784 | 0.530546(±0.005873) |
| NDCG@10 | 0.499655 | 0.504544 | 0.506967 | 0.513855 | 0.501728 | 0.505350(±0.008505) |
| MAP@10 | 0.415299 | 0.419840 | 0.423166 | 0.431339 | 0.421243 | 0.422177(±0.009161) |
| MRR@10 | 0.592257 | 0.592858 | 0.596109 | 0.610589 | 0.590023 | 0.596367(±0.014222) |
+--------------+----------+----------+----------+----------+----------+----------------------+
2019/11/07 10:06:52 Complete cross validation (3m52.912709237s)
複製代碼
目前看起來(咱們其實沒有好好調參),KNN算法在咱們的數據集上表現最好,速度也使人滿意,因此咱們選擇KNN做爲本案例的推薦算法。沒有一個推薦算法必定因爲其餘算法,最佳的算法取決於數據集的特性,例如MovieLens 100K上最佳模型是WRMF而不是KNN。
選擇好模型,咱們將數據導入gorse的內置數據庫,建立一個文件夾data
用於存在數據,將數據導入到data/gorse.db
中:
$ mkdir data
$ gorse import-feedback data/gorse.db games.csv --sep ','
複製代碼
接下來建立推薦服務的配置文件config/gorse.toml
,須要設置服務器監聽地址、端口、數據庫文件位置、一些瑣碎的推薦配置,隱式KNN不須要超參,因此[params]
處留空。
# This section declares settings for the server.
[server]
host = "0.0.0.0" # server host
port = 8080 # server port
# This section declares setting for the database.
[database]
file = "data/gorse.db" # database file
# This section declares settings for recommendation.
[recommend]
model = "knn_implicit" # recommendation model
cache_size = 100 # the number of cached recommendations
update_threshold = 10 # update model when more than 10 ratings are added
check_period = 1 # check for update every one minute
similarity = "implicit" # similarity metric for neighbors
# This section declares hyperparameters for the recommendation model.
[params]
複製代碼
保存配置文件後,運行推薦服務器:
$ gorse serve -c config/gorse.toml
...
2019/11/07 16:45:05 update recommends
2019/11/07 16:47:02 update neighbors by implicit
複製代碼
若是出現最後兩行,說明推薦結果已經生成完畢。
咱們可使用gorse提供的RESTful API來獲取推薦結果:
$ curl http://127.0.0.1:8080/recommends/76561197960272226?number=10
[
{
"ItemId": 4540,
"Score": 23.479386364078838
},
...
{
"ItemId": 57300,
"Score": 22.156954153653245
}
]
複製代碼
咱們獲取了10條推薦,包含遊戲ID和推薦評分。
咱們須要鏈接用戶的Steam帳戶獲取庫存遊戲,所以涉及用戶登陸,須要訪問「註冊 Steam 網頁 API 密鑰」頁面向Steam申請API密鑰用來調用API,
接下來能夠準備Flask開發須要的Pythn包了,須要依次安裝:
$ pip install Flask
$ pip install Flask-OpenID
$ pip install Flask-SQLAlchemy
$ pip install uWSGI
複製代碼
咱們能夠在SteamLens下建立一個文件夾steamlens用於存放Flask程序代碼:
$ mkdir steamlens
複製代碼
前端設計不是本文的重點,HTML模板具體代碼可見steamlens/templates,靜態資源可見steamlens/static,倉庫中提供了兩種頁面:
模板 | 做用 | 數據 |
---|---|---|
page_gallery.jinja2 | 展現遊戲列表 | current_time : 時間, title : 標題, items : 遊戲列表, nickname : 擁護暱稱 |
page_app.jinja2 | 展現一款遊戲和類似遊戲列表 | current_time : 時間, item_id : 遊戲ID, title : 標題, items : 類似列表, nickname : 用戶暱稱 |
在編寫後端代碼以前,將配置信息填寫好:
# Configuration for gorse
GORSE_API_URI = 'http://127.0.0.1:8080'
GORSE_NUM_ITEMS = 30
# Configuration for SQL
SQLALCHEMY_DATABASE_URI = 'sqlite:///../data/steamlens.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Configuration for OpenID
OPENID_STIRE = '../data/openid_store'
SECRET_KEY = 'STEAM_API_KEY'
複製代碼
記得要把STEAM_API_KEY換成Steam的密鑰。
咱們首先編寫基本框架和鏈接Steam的功能,文件位於steamlens/app.py
,程序功能以下:
STEAMLENS_SETTINGS
讀取配置;import json
import os.path
import re
from datetime import datetime
from urllib.parse import urlencode
from urllib.request import urlopen
import requests
from flask import Flask, render_template, redirect, session, g
from flask_openid import OpenID
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config.from_envvar('STEAMLENS_SETTINGS')
oid = OpenID(app, os.path.join(os.path.dirname(__file__), app.config['OPENID_STIRE']))
db = SQLAlchemy(app)
#################
# Steam Service #
#################
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
steam_id = db.Column(db.String(40))
nickname = db.Column(db.String(80))
@staticmethod
def get_or_create(steam_id):
rv = User.query.filter_by(steam_id=steam_id).first()
if rv is None:
rv = User()
rv.steam_id = steam_id
db.session.add(rv)
return rv
@app.route("/login")
@oid.loginhandler
def login():
if g.user is not None:
return redirect(oid.get_next_url())
else:
return oid.try_login("http://steamcommunity.com/openid")
@app.route('/logout')
def logout():
session.pop('user_id', None)
return redirect('/pop')
@app.before_request
def before_request():
g.user = None
if 'user_id' in session:
g.user = User.query.filter_by(id=session['user_id']).first()
@oid.after_login
def new_user(resp):
_steam_id_re = re.compile('steamcommunity.com/openid/id/(.*?)$')
match = _steam_id_re.search(resp.identity_url)
g.user = User.get_or_create(match.group(1))
steamdata = get_user_info(g.user.steam_id)
g.user.nickname = steamdata['personaname']
db.session.commit()
session['user_id'] = g.user.id
# Add games to gorse
games = get_owned_games(g.user.steam_id)
data = [{'UserId': int(g.user.steam_id), 'ItemId': int(v['appid']), 'Feedback': float(v['playtime_forever'])} for v in games]
headers = {"Content-Type": "application/json"}
requests.put('http://127.0.0.1:8080/feedback', data=json.dumps(data), headers=headers)
return redirect(oid.get_next_url())
def get_user_info(steam_id):
options = {
'key': app.secret_key,
'steamids': steam_id
}
url = 'http://api.steampowered.com/ISteamUser/' \
'GetPlayerSummaries/v0001/?%s' % urlencode(options)
rv = json.load(urlopen(url))
return rv['response']['players']['player'][0] or {}
def get_owned_games(steam_id):
options = {
'key': app.secret_key,
'steamid': steam_id,
'format': 'json'
}
url = 'http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?%s' % urlencode(options)
rv = json.load(urlopen(url))
return rv['response']['games']
# Create tables if not exists.
db.create_all()
複製代碼
接着在steamlens/app.py
中添加推薦展現功能,使用gorse提供的RESTful API,獲取熱門遊戲、隨機遊戲、個性化推薦遊戲以及某款遊戲的類似遊戲。
#######################
# Recommender Service #
#######################
@app.context_processor
def inject_current_time():
return {'current_time': datetime.utcnow()}
@app.route('/')
def index():
return redirect('/pop')
@app.route('/pop')
def pop():
# Get nickname
nickname = None
if g.user:
nickname = g.user.nickname
# Get items
r = requests.get('%s/popular?number=%d' % (app.config['GORSE_API_URI'], app.config['GORSE_NUM_ITEMS']))
items = [v['ItemId'] for v in r.json()]
# Render page
return render_template('page_gallery.jinja2', title='Popular Games', items=items, nickname=nickname)
@app.route('/random')
def random():
# Get nickname
nickname = None
if g.user:
nickname = g.user.nickname
# Get items
r = requests.get('%s/random?number=%d' % (app.config['GORSE_API_URI'], app.config['GORSE_NUM_ITEMS']))
items = [v['ItemId'] for v in r.json()]
# Render page
return render_template('page_gallery.jinja2', title='Random Games', items=items, nickname=nickname)
@app.route('/recommend')
def recommend():
# Check login
if g.user is None:
return render_template('page_gallery.jinja2', title='Please login first', items=[])
# Get items
r = requests.get('%s/recommends/%s?number=%s' %
(app.config['GORSE_API_URI'], g.user.steam_id, app.config['GORSE_NUM_ITEMS']))
# Render page
if r.status_code == 200:
items = [v['ItemId'] for v in r.json()]
return render_template('page_gallery.jinja2', title='Recommended Games', items=items, nickname=g.user.nickname)
return render_template('page_gallery.jinja2', title='Generating Recommended Games...', items=[], nickname=g.user.nickname)
@app.route('/item/<int:app_id>')
def item(app_id: int):
# Get nickname
nickname = None
if g.user:
nickname = g.user.nickname
# Get items
r = requests.get('%s/neighbors/%d?number=%d' %
(app.config['GORSE_API_URI'], app_id, app.config['GORSE_NUM_ITEMS']))
items = [v['ItemId'] for v in r.json()]
# Render page
return render_template('page_app.jinja2', item_id=app_id, title='Similar Games', items=items, nickname=nickname)
@app.route('/user')
def user():
# Check login
if g.user is None:
return render_template('page_gallery.jinja2', title='Please login first', items=[])
# Get items
r = requests.get('%s/user/%s' % (app.config['GORSE_API_URI'], g.user.steam_id))
# Render page
if r.status_code == 200:
items = [v['ItemId'] for v in r.json()]
return render_template('page_gallery.jinja2', title='Owned Games', items=items, nickname=g.user.nickname)
return render_template('page_gallery.jinja2', title='Synchronizing Owned Games ...', items=[], nickname=g.user.nickname)
複製代碼
咱們使用uWSGI來啓動Flask服務器,所以須要在最外面的文件夾SteamLens
中建立一個uwsgi.ini:
[uwsgi]
# Bind to the specified UNIX/TCP socket using default protocol
socket=0.0.0.0:5000
# Point to the main directory of the Web Site
chdir=/path/to/SteamLens/steamlens/
# Python startup file
wsgi-file=app.py
# The application variable of Python Flask Core Oject
callable=app
# The maximum numbers of Processes
processes=1
# The maximum numbers of Threads
threads=2
# Set internal buffer size
buffer-size=8192
複製代碼
記得須要將chdir改爲文件夾SteamLens/steamlens所在的路徑。最後執行如下命令運行Flask應用:
$ STEAMLENS_SETTINGS ../config/steamlens.cfg uwsgi --ini uwsgi.ini
複製代碼
能夠訪問steamlens.gorse.io/查看在線演示,登陸系統後等待片刻,便可生成個性化推薦結果。針對筆者的推薦結果以下:
筆者熱愛FPS類遊戲,它給我推薦了大量的FPS遊戲。可是,能夠發現推薦的遊戲都比較老,這是由於項目使用的數據集是2013年左右的,隨着Steam更新了隱私策略,目前也沒法在沒有用戶受權的狀況下獲取用戶庫存了。