1. 程式人生 > 程式設計 >使用Flask快速搭建一個Steam遊戲推薦系統

使用Flask快速搭建一個Steam遊戲推薦系統

有人沉迷於刷抖音,有人沉迷於刷知乎,推薦系統如今已經影響甚至控制著人們的生活。本文將從最簡單的演演算法和流程入手,使用Flask和gorse快速搭建一個Steam遊戲推薦系統。

推薦系統架構

在開始開發之前,我們需要設計一下我的推薦系統的架構,如下圖所示:

可以分割為三個部分:

  • gorse: gorse是一個離線推薦系統,向它提交使用者-遊戲購買記錄,它可以自動訓練模型,生成遊戲推薦列表;
  • Flask: 使用Flask編寫的Web服務負責使用者登入、從Steam請求使用者庫存資訊,向gorse推送庫存資訊,以及拉取推送結果;
  • Steam: 通過API提供庫存資訊,以及提供遊戲封面圖片。

這個Steam遊戲推薦系統已經部署到了steamlens.gorse.io,如果有Steam賬號以及能夠訪問Steam社群的方法(你懂的),可以嘗試一下它的個性化推薦效果。程式碼也開源在了GitHub上,如果有能夠訪問Steam社群伺服器的VPS,那麼可以嘗試自己部署。

建立推薦系統伺服器

安裝

首先我們需要安裝推薦系統後端gorse,如果已經安裝Go語言環境,將$GOBIN加入環境變數$PATH,那麼可以直接使用以下命令安裝:

$ 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,40,50,60,70,130,80,100,0
複製程式碼

可以發現資料有三列,分別是使用者、遊戲和時長。

測試模型

在建立推薦服務之前,需要選擇最適合的推薦演演算法,gorse提供來對各種模型進行評估,可以執行gorse test -h或者檢視

線上檔案學習如何使用。我們的資料集屬於帶權(遊戲時長)隱式反饋,根據各個模型支援的輸入,可以使用四種模型:item-popknn_implicitbprwrmf

首先測試一下非個性化推薦,作為基準:

$ 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:

$ 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,因為遊戲時長的數值非常大,我們需要設定一個小的權重係數\alpha=0.01

$ 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開發環境

接下來可以準備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,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,程式功能如下:

  1. 建立一個Flask app物件,從環境變數STEAMLENS_SETTINGS讀取配置;
  2. 建立OpenID物件,用於連線Steam認證;
  3. 建立SQLAlchemy物件,用於連線資料庫;
  4. 當使用者登入後,獲取使用者名稱和ID儲存到資料庫,將庫存遊戲列表推送至gorse伺服器。
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'],title='Random Games',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',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',nickname=nickname)


@app.route('/user')
def user():
    # Check login
    if g.user is None:
        return render_template('page_gallery.jinja2',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',title='Synchronizing Owned Games ...',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更新了隱私策略,目前也無法在沒有使用者授權的情況下獲取使用者庫存了。