《机器学习》《数据挖掘》课程设计--推荐算法(“CCSCW2024”赛题)

赛题复现

要求为“学者网(SCHOLAT)”构建一个可解释的学者推荐系统。现有数据集包含来自 SCHOLAT 的 9,537 名用户的用户属性和链接。 “attributes”目录包含 9,537 个用户属性文件,用户 ID 为文件名。 链路数据分为“train.csv”和“test.csv”两部分,分别包括 128,924 条和 32,231 条无向边。

用户属性文件样例如下:

链路csv文件样例如下:

摘要

  • [目的] 本研究旨在开发一个可解释的学者推荐系统,通过分析用户属性数据并生成推荐模型,同时确保模型具备一定的可解释性。
  • [方法] 我们采用了数据预处理、推荐模型构建以及模型评估和解释性分析三大步骤。
  • [结果] 处理后的数据有效提升了模型的推荐准确性,同时生成的推荐模型在评估中表现出色,并且通过可解释性方法使得模型推荐结果更加透明。
  • [局限] 数据的多样性和规模限制了模型的泛化能力,未来可通过扩展数据源提升模型表现。
  • [结论] 本研究成功构建了一个具有可解释性的学者推荐系统,对学者间交流和推动具有重要意义。

关键: 学者推荐系统;数据预处理;可解释性;模型评估

1. 引言

在信息过载的时代,学者推荐系统能够帮助研究人员快速找到相关的学术资源。本研究对象为“CCSCW2024”赛题,旨在构建一个可解释的学者推荐系统,通过处理用户属性数据,构建和评估推荐模型,并通过可解释性方法提升模型的透明度。

该赛题提供来自 SCHOLAT的9,537名用户的属性和链接。attributes目录包含9,537个用户属性文件,用户ID为文件名。链路数据为train.csv和test.csv两部分,分别包括128,924条和32,231 条无向边。

2. 解决思路

2.1 数据预处理

2.1.1 对用户属性文件

数据预处理是推荐系统构建的重要步骤。由于直接下载得到的用户属性文件中的内容相对混乱,如用户301的“ai”与用户312的“人工智能”实则可归类为同一种说法的属性;如用户101的属性有1028行,许多相对无用的信息显得很杂乱;又如用户9520的属性直接显示“错误”……这些问题无疑对我们下游的推荐任务是不利的。因此,经过我们的筛选,我们通过以下步骤处理用户属性数据:

① 文本清洗和替换 

使用python“re”库中的正则表达式匹配中文和英文单词,并进行特定关键词的替换。

原属性文件中的词

替换后的词

sql、database、数据库系统

数据库

arm、linux、windows

操作系统

Ai、ai

人工智能

knn、cnn、rnn、tnn

神经网络

tfs、分布式文件系统

文件系统

nlp

自然语言处理

internet、net

网络

Javaweb、javaee、tomcat

java\nweb

本科、本科生、研究生、硕士、博士生、博士、在读

学生

讲师、教授、副教授、研究院、副研究员、主任、副主任、老师、教师

导师

竞赛、教材、论文、期刊、会议、教研、教学、杂志、论坛、理论、sci、ieee

技术

experience、个人空间、biographyintroduction、biography、研究兴趣、错误

(空)

② 行过滤和添加

对含特定关键词的行进行处理。

  • 对除了①中涉及到的英语单词以及具有“特定含义”的、筛选出的“python”和“java”和“matlab”和“gpu”和“web”和“android”和“ios”和“huawei”除外,全部替换为“外国交流”;
  • 确保每个用户属性文件经过替换处理后不会出现重复行。
词频统计和过滤

统计词频,并过滤掉出现次数少于25次的词语,保证数据质量。

我们发现出现次数最少的10000个词中,每个词至多各自出现4次,且大多是如“学历证书”、“草稿”之类对重要属性影响不大的词;出现次数最少的15000个词中,每个词至多各自出现8次,包含一些校名或专有名词英文缩写;而出现次数最少的20000个词,每个词至多各自出现27次,相较于8次有了不小的增加。且一共9537个用户,20000条属性平均在每个用户上相当于每个用户去除2.1条属性。故我们将阈值下限定为25次。

在后续分析中我们能看出这对于后续的嵌入和预测准确度是有利的。

代码

wordcount.py

import os
from collections import Counter
import re

def process_file(input_folder):
    word_counter = Counter()
    
    for filename in os.listdir(input_folder):
        if filename.endswith('.txt'):
            input_file = os.path.join(input_folder, filename)

            with open(input_file, 'r', encoding='utf-8') as f:
                content = f.read()
                # 使用正则表达式来匹配中文单词和英文单词
                words = re.findall(r'[\u4e00-\u9fff]+|\b\w+\b', content)
                word_counter.update(words)
    
    return word_counter

def get_least_common_words(word_counter, n=30):
    # 获取出现次数最少的n个词
    least_common_words = word_counter.most_common()[:-n-1:-1]
    return least_common_words

input_folder = r"\scholarnet\attributes_oral"
word_counts = process_file(input_folder)
# 获取出现次数最少的20000个词
least_common_words = get_least_common_words(word_counts, 20000)

# 打印出现次数最少的30个词
for word, count in least_common_words:
    print(f'{word}: {count}')

dataprocess.py

import os
import re
from collections import Counter
from wordcloud import WordCloud
import matplotlib.pyplot as plt

def count_words(input_folder):
    word_counter = Counter()
    
    for filename in os.listdir(input_folder):
        if filename.endswith('.txt'):
            input_file = os.path.join(input_folder, filename)
            with open(input_file, 'r', encoding='utf-8') as f:
                content = f.read()
                # 使用正则表达式来匹配中文单词和英文单词
                words = re.findall(r'[\u4e00-\u9fff]+|\b\w+\b', content)
                word_counter.update(words)
    
    return word_counter

def process_file(input_file, output_file, word_counter):
    with open(input_file, 'r', encoding='utf-8') as f:
        lines = f.readlines()

    processed_lines = []
    unique_lines = set()  # 用于跟踪已经添加的行
    foreign_added = False  # 标志变量,确保只添加一次"外国"
    technology_added = False  # 标志变量,确保只添加一次"技术"
    student_added = False  # 标志变量,确保只添加一次"学生"
    teacher_added = False  # 标志变量,确保只添加一次"导师"
    
    for line in lines:
        # 替换 tomcat, javaee, javaweb
        if re.search(r'\b(tomcat|javaee|javaweb)\b', line):
            line = re.sub(r'\b(tomcat|javaee|javaweb)\b', 'java\nweb', line)
        if re.search(r'\b(导师)\b', line):
            teacher_added = True
        if re.search(r'\b(学生)\b', line):
            student_added = True
        if re.search(r'\b(技术)\b', line):
            technology_added = True
        line = line.replace("sql", "数据库")
        line = line.replace("database", "数据库")
        line = line.replace("数据库系统", "数据库")
        line = line.replace("arm", "体系结构")
        line = line.replace("xml", "计算机")
        line = line.replace("computer", "计算机")
        line = line.replace("linux", "操作系统")
        line = line.replace("windows", "操作系统")
        line = line.replace("system", "系统")
        line = line.replace("internet", "网络")
        line = line.replace("net", "网络")
        line = line.replace("ai", "人工智能")
        line = line.replace("Ai", "人工智能")
        line = line.replace("knn", "神经网络")
        line = line.replace("cnn", "神经网络")
        line = line.replace("rnn", "神经网络")
        line = line.replace("tnn", "神经网络")
        line = line.replace("tfs", "文件系统")
        line = line.replace("分布式文件系统", "文件系统")
        line = line.replace("nlp", "自然语言处理")
        line = line.replace("experience", "")
        line = line.replace("biographyintroduction", "")
        line = line.replace("biography", "")
        line = line.replace("研究兴趣", "")
        line = line.replace("个人空间", "")
        line = line.replace("错误", "")
        # 若这一行中有“工程”,且不仅有工程,则这一行中保留除工程之外的内容,新建一行,新行的内容是“工程”
        if "工程" in line and line.strip() != "工程":
            line = line.replace("工程", "").strip()
            if line and line not in unique_lines:  # Only add non-empty lines
                processed_lines.append(line + '\n')
                unique_lines.add(line)
            if "工程" not in unique_lines:
                processed_lines.append("工程\n")
                unique_lines.add("工程")
        if not technology_added and re.search(r'\b(竞赛|教材|论文|期刊|会议|教研|教学|杂志|论坛|理论|sci|ieee)\b', line):
            processed_lines.append('技术\n')
            unique_lines.add('技术')
            technology_added = True
        if technology_added and re.search(r'\b(竞赛|教材|论文|期刊|会议|教研|教学|杂志|论坛|理论|sci|ieee)\b', line):
            continue
        if not foreign_added and re.search(r'\b(?!sql|database|python|java|matlab|gpu|web|android|ios|huawei)\b\w*[a-zA-Z]\w*', line):
            processed_lines.append('外国交流\n')
            unique_lines.add('外国交流')
            foreign_added = True
        if foreign_added and re.search(r'\b(?!sql|database|python|java|matlab|gpu|web|android|ios|huawei)\b\w*[a-zA-Z]\w*', line):
            continue
        # 将这些词替换为学生,且仅添加一次
        if not student_added and re.search(r'\b(本科|本科生|研究生|硕士|博士生|博士|在读)\b', line):
            processed_lines.append('学生\n')
            unique_lines.add('学生')
            student_added = True
        if student_added and re.search(r'\b(本科|本科生|研究生|博士生|硕士|博士|在读)\b', line):
            continue
        # 将这些词替换为导师,且仅添加一次
        if not teacher_added and re.search(r'\b(讲师|教授|副教授|研究院|副研究员|主任|副主任|老师|教师)\b', line):
            processed_lines.append('导师\n')
            unique_lines.add('导师')
            teacher_added = True
        if teacher_added and re.search(r'\b(讲师|教授|副教授|研究院|副研究员|主任|副主任|老师|教师)\b', line):
            continue
        # 过滤出现次数少于20的词
        words = re.findall(r'[\u4e00-\u9fff]+|\b\w+\b', line)
        filtered_words = [word for word in words if word_counter[word] >= 20]
        if not filtered_words:
            continue
        line = ' '.join(filtered_words)
        # Remove blank lines and ensure unique
        if line.strip() and line not in unique_lines:
            processed_lines.append(line + '\n')
            unique_lines.add(line)
    
    with open(output_file, 'w', encoding='utf-8') as f:
        f.writelines(processed_lines)

def process_files(input_folder, output_folder):
    if not os.path.exists(output_folder):
        os.makedirs(output_folder)

    word_counter = count_words(input_folder)

    for filename in os.listdir(input_folder):
        if filename.endswith('.txt'):
            input_file = os.path.join(input_folder, filename)
            output_file = os.path.join(output_folder, filename)
            process_file(input_file, output_file, word_counter)

def generate_wordcloud(text, output_image):
    wordcloud = WordCloud(font_path='simhei.ttf', width=800, height=400).generate(text)
    plt.figure(figsize=(10, 5))
    plt.imshow(wordcloud, interpolation='bilinear')
    plt.axis('off')
    plt.savefig(output_image)
    plt.show()

input_folder = r"\scholarnet\attributes_oral"
output_folder = r"\scholarnet\attributes_pro"
process_files(input_folder, output_folder)

# 收集处理后的文本内容
processed_text = ''
for filename in os.listdir(input_folder):
    if filename.endswith('.txt'):
        with open(os.path.join(output_folder, filename), 'r', encoding='utf-8') as f:
            text = f.read()
            processed_text += text

# 生成词云图
output_image = r"./wordcloudin.png"
generate_wordcloud(processed_text, output_image)

 2.1.2 对链路

我们采取了随机生成与训练集train.csv中正确链路条数相同的负样本的方式进行数据增强,在报告的后续部分中我们也能看出这项工作对最终结果的影响以及可能的原因分析。

代码
# 生成负样本
num_negative_samples = len(train_links)
all_nodes = list(range(num_users))
positive_edges = set((row['source'], row['target']) for _, row in train_links.iterrows())

negative_samples = set()
while len(negative_samples) < num_negative_samples:
    source = random.choice(all_nodes)
    target = random.choice(all_nodes)
    if source != target and (source, target) not in positive_edges and (target, source) not in positive_edges:
        negative_samples.add((source, target))

negative_samples = list(negative_samples)

# 将负样本添加到训练集中
negative_samples_df = pd.DataFrame(negative_samples, columns=['source', 'target'])
negative_samples_df['label'] = 0
train_links['label'] = 1
train_data = pd.concat([train_links, negative_samples_df], ignore_index=True)

2.2 推荐过程

2.2.1 直接使用相似度做协同过滤推荐

经过对资料[1]的搜索,我们查阅到协同过滤算法(基于内容/行为)是推荐系统中最常用最简单的思路,完全根据余弦相似度等距离的计算来评定两个用户是否值得被推荐。故我们在一开始使用了这种思路:

  1. 读取用户属性文件;
  2. TF-IDF向量化提取特征;
  3. 计算用户向量间的相似度;
  4. 完成由相似度的最相似k个推荐。
代码
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import os
import pandas as pd

# 读取用户属性文件
def read_user_attributes(directory):
    user_attributes = {}
    for filename in os.listdir(directory):
        if filename.endswith('.txt'):
            user_id = filename.split('.')[0]
            with open(os.path.join(directory, filename), 'r', encoding='utf-8') as file:
                attributes = file.read()
                user_attributes[user_id] = attributes
    return user_attributes

# 特征提取(TF-IDF向量化)
def extract_features(user_attributes):
    user_ids = list(user_attributes.keys())
    documents = list(user_attributes.values())
    vectorizer = TfidfVectorizer()
    feature_matrix = vectorizer.fit_transform(documents)
    return user_ids, feature_matrix, vectorizer

# 计算用户相似度
def compute_similarity(feature_matrix):
    return cosine_similarity(feature_matrix)

# 基于相似度的推荐
def recommend(similarity_matrix, user_ids, input_user_id, top_k=5):
    recommendations = {}
    if input_user_id not in user_ids:
        return recommendations
    idx = user_ids.index(input_user_id)
    similar_indices = np.argsort(-similarity_matrix[idx])[1:top_k+1]
    similar_users = [user_ids[i] for i in similar_indices]
    recommendations[input_user_id] = similar_users
    return recommendations

# 找到推荐理由
def find_reasons(input_text, recommended_text, vectorizer):
    input_tokens = vectorizer.build_analyzer()(input_text)
    recommended_tokens = vectorizer.build_analyzer()(recommended_text)
    common_tokens = set(input_tokens).intersection(set(recommended_tokens))
    return common_tokens

# 评估模型
def evaluate(test_file, user_attributes_dir):
    user_attributes = read_user_attributes(user_attributes_dir)
    user_ids, feature_matrix, vectorizer = extract_features(user_attributes)
    similarity_matrix = compute_similarity(feature_matrix)
    
    test_links = pd.read_csv(test_file, names=['source', 'target'], skiprows=1)
    
    k = 5
    precision_sum = 0
    recall_sum = 0
    total = 0
    predictions = []
    true_labels = []
    
    for index, row in test_links.iterrows():
        source, target = str(row['source']), str(row['target'])
        if source in user_ids and target in user_ids:
            idx = user_ids.index(source)
            similarities = similarity_matrix[idx]
            top_k_indices = np.argsort(-similarities)[1:k+1]
            top_k_users = [user_ids[i] for i in top_k_indices]
            
            if target in top_k_users:
                precision_sum += 1
            
            recall_sum += 1  # 每次计算都增加1,因为在这情况下我们只测试一个连接
            
            predictions.append(similarities[user_ids.index(target)])
            true_labels.append(1)
            total += 1
    
    precision = precision_sum / total if total > 0 else 0
    recall_at_k = recall_sum / total if total > 0 else 0
    rmse = np.sqrt(np.mean((np.array(predictions) - np.array(true_labels)) ** 2))
    mae = np.mean(np.abs(np.array(predictions) - np.array(true_labels)))
    
    unique_nodes = set(test_links['source']).union(set(test_links['target']))
    coverage = len(unique_nodes) / len(user_ids)
    
    print(f'Precision: {precision}')
    print(f'Recall@k: {recall_at_k}')
    print(f'RMSE: {rmse}')
    print(f'MAE: {mae}')
    print(f'Coverage: {coverage}')

# 设置目录和文件路径
user_attributes_dir = r"\scholarnet\attributes_pro"
test_file = r"\scholarnet\test.csv"

# 评估模型
evaluate(test_file, user_attributes_dir)

2.2.2 使用GNN图神经网络

因为提供的数据集还包含了train和test两个链路的csv文件,故我们认为可以采取机器学习的方式获取预测的推荐链路。图神经网络(GNN)是一种专门处理图结构数据的深度学习模型,通过节点和边的信息捕捉图中的关系和拓扑结构。常见的GNN模型包括图卷积网络(GCN)、图注意力网络(GAT)和图同构网络(GIN)。GNN在社交网络分析、化学分子预测、推荐系统和交通网络等领域有广泛应用,其核心是通过消息传递和特征聚合更新节点表示,从而实现图数据的学习和预测:

  1. 读取用户属性文件和链路表文件;
  2. 使用one-hot编码和python“torch”库nn.Embedding方法两种方式构建用户特征矩阵;
  3. 构建图数据集和图神经网络模型;
  4. 完成基于模型预测结果的推荐。
代码
import torch
import torch.nn.functional as F
from torch_geometric.data import Data
import pandas as pd
import os
from torch_geometric.nn import SAGEConv
import numpy as np
import random

# 用于记录最大用户
max_user_id = -1

# 读取用户属性文件
attributes_dir = r"\scholarnet\attributes_pro"
user_attributes = {}
for filename in os.listdir(attributes_dir):
    with open(os.path.join(attributes_dir, filename), 'r', encoding='utf-8') as file:
        lines = file.readlines()
        if len(lines) > 1:
            user_id = int(filename.split('.')[0])
            attributes = [line.strip() for line in lines[1:]]  # 读取除了第一行的所有特征
            max_user_id = max(max_user_id, user_id)
            user_attributes[user_id] = attributes

# 构建用户属性特征矩阵使用embedding
unique_attributes = list(set(attr for attributes in user_attributes.values() for attr in attributes))
attribute_to_index = {attr: idx for idx, attr in enumerate(unique_attributes)}
num_users = max_user_id + 1
num_attributes = len(unique_attributes)

# 构建embedding表示每个用户的多个特征
embedding_dim = 16
embedding_layer = torch.nn.Embedding(num_attributes, embedding_dim)
user_features = torch.zeros((num_users, embedding_dim))
for user_id, attributes in user_attributes.items():
    attribute_indices = torch.tensor([attribute_to_index[attr] for attr in attributes])
    user_features[user_id] = torch.mean(embedding_layer(attribute_indices), dim=0)

train_links = pd.read_csv(r"\scholarnet\train.csv", names=['source', 'target'], skiprows=1)

# 构建图数据
edge_index = torch.tensor(train_links.values.T, dtype=torch.long)
data = Data(x=user_features, edge_index=edge_index)

# 定义图神经网络模型
class GNN(torch.nn.Module):
    def __init__(self, in_channels, out_channels):
        super(GNN, self).__init__()
        self.conv1 = SAGEConv(in_channels, 128)
        self.conv2 = SAGEConv(128, out_channels)

    def forward(self, data):
        x, edge_index = data.x, data.edge_index
        x = F.relu(self.conv1(x, edge_index))
        x = self.conv2(x, edge_index)
        return x

# 生成负样本
num_negative_samples = len(train_links)
all_nodes = list(range(num_users))
positive_edges = set((row['source'], row['target']) for _, row in train_links.iterrows())

negative_samples = set()
while len(negative_samples) < num_negative_samples:
    source = random.choice(all_nodes)
    target = random.choice(all_nodes)
    if source != target and (source, target) not in positive_edges and (target, source) not in positive_edges:
        negative_samples.add((source, target))

negative_samples = list(negative_samples)

# 将负样本添加到训练集中
negative_samples_df = pd.DataFrame(negative_samples, columns=['source', 'target'])
negative_samples_df['label'] = 0
train_links['label'] = 1
train_data = pd.concat([train_links, negative_samples_df], ignore_index=True)

# 准备训练数据
edge_index = torch.tensor(train_data[['source', 'target']].values.T, dtype=torch.long)
labels = torch.tensor(train_data['label'].values, dtype=torch.float)

# 重新构建图数据
data = Data(x=user_features, edge_index=edge_index, y=labels)

# 训练模型
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = GNN(embedding_dim, 64).to(device)
data = data.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=6e-3)

model.train()
for epoch in range(25):
    optimizer.zero_grad()
    out = model(data)
    link_pred = F.cosine_similarity(out[data.edge_index[0]], out[data.edge_index[1]])
    loss = F.binary_cross_entropy_with_logits(link_pred, data.y)
    loss.backward(retain_graph=True)
    optimizer.step()
    if epoch % 5 == 0:
        print(f'Epoch {epoch}, Loss: {loss.item()}')

model.eval()
with torch.no_grad():
    embeddings = model(data)

# 预测测试集的准确性
test_links = pd.read_csv(r"\scholarnet\test.csv", names=['source', 'target'], skiprows=1)

# 计算每对节点的相似度
k = 10
precision_sum = 0
recall_sum = 0
total = 0
predictions = []
true_labels = []

for index, row in test_links.iterrows():
    source, target = row['source'], row['target']
    source_embedding = embeddings[source]
    target_embedding = embeddings[target]
    
    # 计算所有用户的相似性
    similarities = F.cosine_similarity(source_embedding.unsqueeze(0), embeddings, dim=1)
    
    # 获取相似性最高的前k个用户
    top_k_indices = torch.topk(similarities, k).indices
    top_k_predictions = top_k_indices.tolist()
    
    # 计算precision
    if target in top_k_predictions:
        precision_sum += 1
        
    # 计算recall
    recall_sum += 1  # 每次计算都增加1,因为在这情况下我们只测试一个连接

    # 用于RMSE和MAE计算
    predictions.append(similarities[target].item())
    true_labels.append(1)
    total += 1

# 计算平均precision
precision = precision_sum / total

# 计算recall@k
recall_at_k = recall_sum / total

# 计算RMSE
rmse = np.sqrt(np.mean((np.array(predictions) - np.array(true_labels)) ** 2))

# 计算MAE
mae = np.mean(np.abs(np.array(predictions) - np.array(true_labels)))

# 计算覆盖率
unique_nodes = set(test_links['source']).union(set(test_links['target']))
coverage = len(unique_nodes) / num_users

print(f'Precision: {precision}')
print(f'Recall@k: {recall_at_k}')
print(f'RMSE: {rmse}')
print(f'MAE: {mae}')
print(f'Coverage: {coverage}')

2.2.3 使用node2vec和相似度结合

由于图神经网络和隐藏层属于黑盒,又train.csv的链路构成一个无向图,所以我们想到了图嵌入模型来储存“用户-边-用户”的关系。Node2vec是一种用于图嵌入的算法,通过随机游走模拟生成节点序列,并使用Word2vec模型将这些序列中的节点嵌入到低维向量空间。其核心思想是结合DFS和BFS策略,以平衡探索节点邻域的广度和深度,从而捕捉节点的局部和全局结构信息。这些嵌入向量可用于节点分类、链接预测等任务:

  1. 读取用户属性文件和链路表文件;
  2. 根据train.csv构建图数据集;
  3. 拟合node2vec模型,做图嵌入;
  4. 对向量化的节点计算用户相似度;
  5. 完成由相似度的最相似k个推荐。

并在此基础上衍生出后续的有关可解释性的内容。

代码

pred.py

import numpy as np
from sklearn.preprocessing import normalize
from node2vec import Node2Vec
import networkx as nx
import pandas as pd
import torch
import torch.nn.functional as F
from sklearn.metrics import mean_squared_error, mean_absolute_error
import os

# Read user attributes from directory
def read_user_attributes(directory):
    user_attributes = {}
    for filename in os.listdir(directory):
        if filename.endswith('.txt'):
            user_id = filename.split('.')[0]
            with open(os.path.join(directory, filename), 'r', encoding='utf-8') as file:
                attributes = file.read()
                user_attributes[user_id] = attributes.strip().split()
    return user_attributes

# Build user graph based on edges file
def build_user_graph(user_attributes, edges_file):
    G = nx.Graph()
    edges = pd.read_csv(edges_file)
    for _, row in edges.iterrows():
        user1, user2 = str(row.iloc[0]), str(row.iloc[1])
        if user1 in user_attributes and user2 in user_attributes:
            G.add_edge(user1, user2)
    return G

# Train node2vec model
def train_node2vec(G):
    node2vec = Node2Vec(G, dimensions=64, walk_length=10, num_walks=10, workers=4)
    model = node2vec.fit(window=10, min_count=1, batch_words=4)
    return model

# Extract node embeddings
def extract_node_embeddings(G, model):
    node_embeddings = {}
    for node in G.nodes():
        try:
            node_embeddings[node] = model.wv[node]
        except KeyError:
            node_embeddings[node] = np.zeros(model.vector_size)
    return node_embeddings

# Compute node similarity
def compute_node_similarity(node_embeddings):
    nodes = list(node_embeddings.keys())
    embeddings = np.array([node_embeddings[node] for node in nodes])
    normalized_embeddings = normalize(embeddings)
    similarity_matrix = np.dot(normalized_embeddings, normalized_embeddings.T)
    return similarity_matrix, nodes

# Main function
def main(user_attributes_dir, edges_file, test_file):
    user_attributes = read_user_attributes(user_attributes_dir)
    G = build_user_graph(user_attributes, edges_file)
    model = train_node2vec(G)
    node_embeddings = extract_node_embeddings(G, model)
    
    test_data = pd.read_csv(test_file)
    embeddings = {node: torch.tensor(embedding) for node, embedding in node_embeddings.items()}
    
    k = 10
    precision_sum = 0
    recall_sum = 0
    total = 0
    predictions = []
    true_labels = []
    unique_nodes = set()
    
    for index, row in test_data.iterrows():
        source, target = str(row['source']), str(row['target'])
        if source in embeddings and target in embeddings:
            source_embedding = embeddings[source]
            target_embedding = embeddings[target]
            
            # 计算所有用户的相似性
            similarities = F.cosine_similarity(source_embedding.unsqueeze(0), torch.stack(list(embeddings.values())), dim=1).numpy()
            
            # 获取相似性最高的前k个用户
            top_k_indices = np.argsort(-similarities)[1:k+1]
            top_k_predictions = [list(embeddings.keys())[i] for i in top_k_indices]
            
            # 计算precision
            if target in top_k_predictions:
                precision_sum += 1
            
            # 用于RMSE和MAE计算
            predictions.append(similarities[list(embeddings.keys()).index(target)])
            true_labels.append(1)
            
            total += 1
            unique_nodes.add(source)
            unique_nodes.add(target)

    # 计算平均precision
    precision = precision_sum / total

    # 计算RMSE
    rmse = np.sqrt(mean_squared_error(true_labels, predictions))

    # 计算MAE
    mae = mean_absolute_error(true_labels, predictions)

    # 计算覆盖率
    coverage = len(unique_nodes) / len(embeddings)

    print(f'Precision: {precision:.2%}')
    print(f'RMSE: {rmse:.4f}')
    print(f'MAE: {mae:.4f}')
    print(f'Coverage: {coverage:.2%}')

# Set paths and call main function
user_attributes_dir = r"\scholarnet\attributes_pro"
edges_file = r"\scholarnet\train.csv"
test_file = r"\scholarnet\test.csv"

main(user_attributes_dir, edges_file, test_file)

exp.py

# 1.使用node2vec做图嵌入(用户-边-用户)
# 2.使用节点相似度计算,选出最相似的前5个用户在终端中输出推荐
# 3.推荐理由为用户属性的编辑距离大于阈值的属性,作为文本可解释的功能
# 4.输出一张networkx关系图谱作为可视化可解释的功能
# 5.输出词云图,左边为所有用户的所有属性,右边为被推荐用户的所有属性
# 6.输出热力图

import numpy as np
from sklearn.preprocessing import normalize
from node2vec import Node2Vec
import networkx as nx
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import TfidfVectorizer
import os
import pandas as pd
from matplotlib.font_manager import FontProperties
import seaborn as sns
from wordcloud import WordCloud, STOPWORDS
import random

sns.set_theme(font='SimHei')

# Read user attributes from directory
def read_user_attributes(directory):
    user_attributes = {}
    for filename in os.listdir(directory):
        if filename.endswith('.txt'):
            user_id = filename.split('.')[0]
            with open(os.path.join(directory, filename), 'r', encoding='utf-8') as file:
                attributes = file.read()
                user_attributes[user_id] = attributes.strip().split()
    return user_attributes

# Build user graph based on edges file
def build_user_graph(user_attributes, edges_file):
    G = nx.Graph()
    edges = pd.read_csv(edges_file)
    for _, row in edges.iterrows():
        user1, user2 = str(row.iloc[0]), str(row.iloc[1])
        if user1 in user_attributes and user2 in user_attributes:
            G.add_edge(user1, user2)
    return G

# Train node2vec model
def train_node2vec(G):
    node2vec = Node2Vec(G, dimensions=64, walk_length=10, num_walks=10, workers=4)
    model = node2vec.fit(window=10, min_count=1, batch_words=4)
    return model

# Extract node embeddings
def extract_node_embeddings(G, model):
    node_embeddings = {}
    for node in G.nodes():
        try:
            node_embeddings[node] = model.wv[node]
        except KeyError:
            node_embeddings[node] = np.zeros(model.vector_size)
    return node_embeddings

# Compute node similarity
def compute_node_similarity(node_embeddings):
    nodes = list(node_embeddings.keys())
    embeddings = np.array([node_embeddings[node] for node in nodes])
    normalized_embeddings = normalize(embeddings)
    similarity_matrix = np.dot(normalized_embeddings, normalized_embeddings.T)
    return similarity_matrix, nodes

# Recommend based on similarity
def recommend(similarity_matrix, nodes, input_user_id, top_k=5):
    recommendations = {}
    if input_user_id not in nodes:
        return recommendations
    idx = nodes.index(input_user_id)
    similar_indices = np.argsort(-similarity_matrix[idx])[1:top_k+1]
    similar_nodes = [nodes[i] for i in similar_indices]
    recommendations[input_user_id] = similar_nodes
    return recommendations

# Find reasons for recommendations
def find_reasons(input_text, recommended_text, vectorizer, similarity_threshold=0.1):
    input_tokens = vectorizer.build_analyzer()(input_text)
    recommended_tokens = vectorizer.build_analyzer()(recommended_text)
    
    common_tokens = set()
    for token1 in input_tokens:
        for token2 in recommended_tokens:
            similarity_score = edit_distance_similarity(token1, token2)
            if similarity_score > similarity_threshold:
                common_tokens.add(token1)
                common_tokens.add(token2)
    
    return common_tokens

def edit_distance_similarity(token1, token2):
    if token1 == token2:
        return 1.0
    else:
        edit_distance = edit_distance_function(token1, token2)
        max_length = max(len(token1), len(token2))
        similarity = 1 - (edit_distance / max_length)
        return similarity

def edit_distance_function(token1, token2):
    if token1 == token2:
        return 0
    elif len(token1) == 0:
        return len(token2)
    elif len(token2) == 0:
        return len(token1)
    else:
        if token1[0] == token2[0]:
            cost = 0
        else:
            cost = 1
        return min(edit_distance_function(token1[1:], token2) + 1,
                   edit_distance_function(token1, token2[1:]) + 1,
                   edit_distance_function(token1[1:], token2[1:]) + cost)

# Draw network graph
def draw_graph(G, similarity_matrix, nodes, input_user_id, recommendations, user_attributes):
    G_plot = nx.Graph()
    G_plot.add_nodes_from(nodes)
    
    threshold = 0.75
    for i in range(len(nodes)):
        for j in range(i + 1, len(nodes)):
            if similarity_matrix[i, j] > threshold:
                G_plot.add_edge(nodes[i], nodes[j])
    
    node_sizes = []
    node_colors = []
    for node in nodes:
        if node == input_user_id:
            node_sizes.append(650)
            node_colors.append('yellow')
        elif node in recommendations.get(input_user_id, []):
            node_sizes.append(650)
            node_colors.append('orange')
        else:
            node_sizes.append(60)
            node_colors.append('lightblue')
    
    pos = nx.spring_layout(G_plot)
    pos[input_user_id] = np.array([0, 0])
    
    num_recommendations = len(recommendations[input_user_id])
    angle_step = 2 * np.pi / num_recommendations
    
    for i, rec_user in enumerate(recommendations[input_user_id]):
        angle = i * angle_step
        x = np.cos(angle) * 1.4
        y = np.sin(angle) * 1.4
        pos[rec_user] = np.array([x, y])
        G_plot.add_edge(input_user_id, rec_user)
    
    plt.figure(figsize=(15, 9))
    nx.draw(G_plot, pos, with_labels=False, node_size=node_sizes, node_color=node_colors, font_size=4)
    
    for node in G_plot.nodes:
        if node == input_user_id:
            plt.annotate(f'To User {node}', xy=pos[node], xytext=(-20, 20),
                         textcoords='offset points', ha='center', va='center',
                         bbox=dict(boxstyle='round,pad=0.5', fc='yellow', alpha=0.5),
                         arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0'))
        elif node in recommendations.get(input_user_id, []):
            attributes_str = ' '.join(user_attributes[node])
            plt.annotate(f'Recommended User {node}:\n{attributes_str}', xy=pos[node], xytext=(20, -20),
                         textcoords='offset points', ha='center', va='center',
                         bbox=dict(boxstyle='round,pad=0.5', fc='orange', alpha=0.5))
    
    plt.title('Similar Users Graph')
    plt.show()

# Draw wordclouds
def draw_wc(user_attributes, recommendations, input_user_id):
    all_text = ' '.join([' '.join(attrs) for attrs in user_attributes.values()])
    rec_text = ' '.join([' '.join(user_attributes[rec_id]) for rec_id in recommendations[input_user_id]])
    
    plt.figure(figsize=(13, 6))
    plt.subplot(1, 2, 1)
    plot_wordcloud('All Users', all_text)
    plt.subplot(1, 2, 2)
    plot_wordcloud('Recommended Users', rec_text)
    plt.tight_layout()
    plt.show()

def plot_wordcloud(title, text):
    wc = WordCloud(font_path='simhei.ttf', width=800, height=400, background_color='white', stopwords=STOPWORDS).generate(text)
    plt.imshow(wc, interpolation='bilinear')
    plt.axis('off')
    plt.title(title, fontsize=14)

def generate_heatmap(user_attributes, recommendations, input_user_id):
    input_user_attrs = user_attributes[input_user_id]
    attributes_set = set(input_user_attrs)
    
    attributes_list = sorted(attributes_set)
    user_list = list(user_attributes.keys())
    
    attribute_matrix = np.zeros((len(user_list), len(attributes_list)))
    
    for i, user_id in enumerate(user_list):
        for attr in user_attributes[user_id]:
            if attr in attributes_set:
                j = attributes_list.index(attr)
                attribute_matrix[i, j] += 1
    
    # Normalize the attribute matrix to scale between -1 and 1
    attribute_matrix = (attribute_matrix - 0.5) * 2
    
    df = pd.DataFrame(attribute_matrix, index=user_list, columns=attributes_list)
    
    recommended_users = recommendations.get(input_user_id, [])
    
    # Select 30 random users excluding the recommended users
    other_users = [user for user in user_list if user not in recommended_users]
    random_selected_users = random.sample(other_users, 30)
    
    selected_users = [input_user_id] + recommended_users + random_selected_users
    df_selected = df.loc[selected_users]
    
    plt.figure(figsize=(13, 13))
    ax = sns.heatmap(df_selected.T, annot=True, cmap='RdYlBu', center=0, linewidths=.5)
    
    # Highlight input user attributes
    input_user_indices = [attributes_list.index(attr) for attr in input_user_attrs]
    
    for idx in input_user_indices:
        ax.get_xticklabels()[selected_users.index(input_user_id)].set_color('red')
    
    plt.title('User Attribute Heatmap')
    plt.show()

# Main function
def main(user_attributes_dir, edges_file, input_user_id):
    user_attributes = read_user_attributes(user_attributes_dir)
    G = build_user_graph(user_attributes, edges_file)
    model = train_node2vec(G)
    node_embeddings = extract_node_embeddings(G, model)
    documents = [' '.join(attrs) for attrs in user_attributes.values()]
    vectorizer = TfidfVectorizer()
    vectorizer.fit(documents)
    similarity_matrix, nodes = compute_node_similarity(node_embeddings)
    recommendations = recommend(similarity_matrix, nodes, input_user_id)
    # Calculate the maximum distance in the graph
    all_distances = dict(nx.all_pairs_shortest_path_length(G))
    max_distance = max(max(d.values()) for d in all_distances.values())
    input_text = ' '.join(user_attributes[input_user_id])
    for user_id, recs in recommendations.items():
        print(f"Recommendations for User {user_id}:")
        for rec_user_id in recs:
            rec_text = ' '.join(user_attributes[rec_user_id])
            reasons = find_reasons(input_text, rec_text, vectorizer)
            # Calculate graph distance
            graph_distance = nx.shortest_path_length(G, source=user_id, target=rec_user_id)
            similarity_score = similarity_matrix[nodes.index(user_id), nodes.index(rec_user_id)]
            print(f"  Recommended User {rec_user_id}:")
            print(f"    Reason: {' '.join(reasons)}")
            print(f"    Similarity Score: {similarity_score:.4f}")
            print(f"    Graph Distance: {graph_distance}")
            print(f"    Max Distance in Graph: {max_distance}")
    
    draw_graph(G, similarity_matrix, nodes, input_user_id, recommendations, user_attributes)
    draw_wc(user_attributes, recommendations, input_user_id)
    generate_heatmap(user_attributes, recommendations, input_user_id)

# 设置路径和输入用户ID
user_attributes_dir = r"\scholarnet\attributes_pro"
edges_file = r"\scholarnet\train.csv"
input_user_id = input("请输入向用户id为几的用户推荐 (输入一个id): ")

# 调用主函数进行推荐和图谱绘制
main(user_attributes_dir, edges_file, input_user_id)

2.3 推荐模型评估

经过我们的自主学习[2]以及老师的指导,我们了解到对于推荐系统的评估主要有如下几种评估指标:

  • 准确度(Precision):指系统推荐的项目中正确推荐的比例。
  • 召回率(Recall):指系统推荐的项目中实际应该被推荐的比例。
  • 覆盖率(Coverage):衡量推荐系统能够推荐的物品种类的广度。
  • 均方根误差(RMSE)、平均绝对误差(MAE):衡量推荐系统预测评分与实际评分之间的差距。
  • 用户满意度、转化率等实际体验

在本次的实训中,我们选择了准确度、覆盖率、均方根误差、平均绝对误差作为标准。具体计算方法如下:

  • 准确度:
  1. 对于每一个测试样本,找到与源用户最相似的前k个用户。
  2. 检查目标用户是否在这前k个用户中。
  3. 如果目标用户在推荐的前k个用户中,则记一次命中。
  4. Precision = (命中次数) / (总测试样本数)
  • 覆盖率:
  1. 统计测试样本中涉及的所有唯一用户数(包括源用户和目标用户)。
  2. 计算这个唯一用户数占总用户数的比例。
  • 均方根误差:
  1. 对于每一个测试样本,计算源用户与目标用户之间的相似度。
  2. 将这些相似度与真实的标签(全为1,因为每个测试样本中的目标用户就是正确的用户)进行比较,计算平方差。
  3. 计算这些平方差的均值,然后取平方根。
  • 平均绝对误差:
  1. 对于每个测试样本,计算源用户与目标用户之间的相似度。
  2. 将这些相似度与真实的标签(同样全为1)进行比较,计算绝对误差。
  3. 计算这些绝对误差的均值。

2.4 可解释性设计

2.4.1 推荐理由

对于推荐理由,考虑到有些用户推荐与被推荐之间的共同兴趣为空、或被推荐用户的兴趣可能某几项并不十分吻合推荐用户,为避免推荐理由为空或差距过大,使用编辑距离拟定推荐理由。使用node2vec推荐时,同时输出用户与被推荐用户在graph中的距离,使得推荐更具有可解释性。使用相似度推荐时,输出两个用户间的相似性。

2.4.2 词云图

可视化展示所有用户属性的词云图与被推荐的用户的词云图的对比,直观体现对用户属性的筛选。

2.4.3 图谱网络

使用python“network”库绘制用户与被推荐用户之间的图谱,直观体现推荐网络。

2.4.4 热力图

使用“matplotlib”和“seaborn”库,将用户的属性转化为数值矩阵,用于生成热力图,直观体现用户在不同属性上的活跃度。

3. 实验及结果分析

3.1 数据预处理结果

处理前:处理后:

我们可以很清晰的看出,经过用户属性处理,用户属性变得更加集中。

3.2 推荐模型评估结果对比分析 

推荐模型1

未处理的属性文件、one-hot编码、GNN网络、epoch=20、lr=0.01、k=5;

推荐模型2

经处理的属性文件、embedding编码、GNN网络、epoch=25、lr=0.01、k=10;

推荐模型3

经处理的属性文件、与train.csv链路条数相同的负样本、embedding编码、GNN网络、epoch=25、lr=0.01、k=10;

推荐模型4

经处理的属性文件、one-hot、GCN网络、epoch=500、lr=0.001、k=5;

  • model1 是基于用户属性的图卷积网络,其输入特征是用户的属性one-hot向量;捕捉的是用户的属性信息和用户之间的相似性。
  • model2 是基于邻接矩阵的图卷积网络,其输入特征是邻接矩阵;捕捉的是用户之间的连接关系和图结构信息。
  • 损失函数:二进制交叉熵。两个模型分别专门处理用户属性特征和节点邻接关系,然后将这两类信息结合起来,更好地捕捉节点之间的关系和信息流动,更精确地学习用户的兴趣和节点之间的相互作用,提高推荐系统的性能。这样做的目的是融合结构信息和属性信息,避免了单个GNN模型偏向于只推荐相似兴趣多的用户的问题。

推荐模型5

经处理的属性文件、使用相似度的协同过滤推荐、k=10;

推荐模型6

经处理的属性文件、使用相似度的协同过滤推荐、k=5;

推荐模型7

经处理的属性文件、使用node2vec图嵌入、dim=64、游走长度=10、k=10;

推荐模型8

经处理的属性文件、使用node2vec图嵌入、dim=64、游走长度=10、k=3。

推荐模型9

经处理的属性文件、使用node2vec图嵌入、dim=128、游走长度=20、k=10。

由于test.csv的唯一性,每个模型此时结果的覆盖率均为89.88%,故不将覆盖率这一项列在对比的图表中。89.88%这个数值也表明验证集覆盖了来自9537个用户中的绝大多数用户,能证明我们模型的结果是具有一定的泛化能力说服力的。而考虑到在实际前端页面中的美观程度,所以我们在对比中选择的topk的最大k值为10。

我们可以很清晰地看出,推荐模型7的推荐性能在准确度和误差的综合考量下在这个数据集上是最高的,我们认为原因大致是由于处理后的用户属性文件属性更加集中、又训练集构建了一个图数据集,故node2vec比单纯的相似性推荐更加契合这个题目背景、而相比于图神经网络的黑盒性,node2vec对于节点之间的关系和节点内容嵌入的更好。对于k,经实验得k的值大致在100时,准确度的值大致达到最大,为73.59%,而此时RMSE、MAE无明显提高。下图是k=100时推荐模型7的运行结果:

对于生成的负链路样本[3],最终性能低下的原因我们猜测对于链路预测的推荐系统而非对于评分预测的推荐系统,不能完全根据train.csv中无链路就完全否定这两个用户之间的联系。就像255与250之间的这条链路,由“计算机网络”链向“android”。同时也可能与我们对用户属性文件做处理时的文本清洗和替换不当有关。

3.3 可视化可解释性展示结果

展示结果以向用户255推荐为例。

3.3.1 推荐理由

其中,由差异不大的兴趣(若存在)、相似度得分(得分范围[0,1])、两个节点在图中的距离以及图中的最大距离组成推荐理由。直观的告诉用户在图中的相似度得分以及在图中的距离,使得用户了解推荐因子。 

3.3.2 词云图 

其中,左边为所有9537个用户的属性词云图,右边为此次向用户255推荐的5个用户的属性词云图。使用户了解对于属性的推荐筛选。

3.3.3 图谱网络 

其中,黄色标注的为用户255,即需要推荐的用户,橙色标注的为此次向用户255推荐的前五(解释性推荐时设定k=5)个最相似的用户。其余小蓝点为在学者网中剩余的所有用户。

3.3.4 热力图

其中,横轴上依次是输入的用户id,推荐的前5名用户id以及另外随机选择(python“random”库中的random.sample()函数)的30个用户id,并突出显示输入的用户。保证热力图在包含足够信息的同时也保持较好的可读性,综合提升推荐系统的透明性。

 4. 其他尝试

4.1 neo4j

对于推荐算法和可解释的部分,尝试过将节点上传至neo4j,使用知识图谱的方式完成该推荐系统。代码如下:

from neo4j import GraphDatabase
import os
import pandas as pd

# 数据路径
attr_dir = r"\scholarnet\attributes"
train_datacsv = r"\scholarnet\train.csv"
test_datacsv = r"\scholarnet\test.csv"

# 数据加载
train_csv = pd.read_csv(train_datacsv)
test_csv = pd.read_csv(test_datacsv)

# 加载用户属性
def load_user_attributes(attr_dir):
    user_attrs = {}
    for filename in os.listdir(attr_dir):
        if filename.endswith(".txt"):
            user_id = int(filename.split('.')[0])
            with open(os.path.join(attr_dir, filename), 'r', encoding='utf-8') as f:
                user_attrs[user_id] = f.read().strip().split()
    return user_attrs

user_attrs = load_user_attributes(attr_dir)

# Neo4j连接信息
uri = "neo4j+s://xxx.databases.neo4j.io"
user = "neo4j"
password = "xxx"
# 创建Neo4j驱动
driver = GraphDatabase.driver(uri, auth=(user, password))
# 导入用户节点和边
def import_data_to_neo4j(train_csv, test_csv, user_attrs):
    with driver.session() as session:
        # 创建用户节点
        for user_id, attrs in user_attrs.items():
            attrs_str = " ".join(attrs)
            session.run(
                "CREATE (u:User {id: $id, attributes: $attributes})",
                id=user_id, attributes=attrs_str)
        
        # 创建训练集边
        for _, row in train_csv.iterrows():
            session.run(
                "MATCH (u1:User {id: $src}), (u2:User {id: $dst}) "
                "CREATE (u1)-[:Train {color: 'blue'}]->(u2)",
                src=row['source'], dst=row['target'])
        
        # 创建测试集边
        for _, row in test_csv.iterrows():
            session.run(
                "MATCH (u1:User {id: $src}), (u2:User {id: $dst}) "
                "CREATE (u1)-[:Test {color: 'red'}]->(u2)",
                src=row['source'], dst=row['target'])
# 导入数据到Neo4j
import_data_to_neo4j(train_csv, test_csv, user_attrs)
# 关闭驱动
driver.close()

结果也是较为可观的,但是由于一些原因没有优化完整。以下是训练集中id0的用户与id76的推荐关系、 id1的用户与id60和66的推荐关系展示:

4.2 评估指标

向ChatGPT请教有没有什么新颖的评估该推荐系统的方法,它给出了“输出的推荐节点与测试集中节点相似度大于某一阈值,即可认为是正确的”这样一种方法,乍一看是合理的,但是在向老师汇报时老师指出该方法的不科学之处:阈值的选取无科学依据。

5. 未来工作

对于推荐系统,未来的工作我们认为大致会朝以下方向发展,也是我们在此次实训过程中未涉及的部分:

  • 冷启动问题:在新用户或新内容出现时,推荐系统难以提供新推荐。
  • 用户隐私保护:在使用用户数据时,需要注意脱敏的问题。
  • 推荐系统的可扩展性:随着数据量和用户数量的增加,推荐系统需要保持高效和可扩展。
  • 增加跨领域推荐:使系统不仅能推荐学术资源,还能推荐相关的学术会议、合作机会等,进一步扩展系统的应用范围。
  • 推荐效率优化:使系统更快的筛选出值得被推荐的用户。

参考文献

[1]     AI天才研究院. 推荐系统:智能分析与用户体验优化. csdn博客,

推荐系统:智能分析与用户体验优化-CSDN博客

[2]    AI天才研究院. 推荐系统的评估与优化. csdn博客,

推荐系统的评估与优化-CSDN博客

[3]     王晨扬. 数据增强中的负样本策略研究[D]. 哈尔滨工业大学,

2022.DOI:10.27061/d.cnki.ghgdu.2021.002627.

本博客封面图截自学者网知识图谱页面。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值