这一篇文章是我们为开放数据基金会撰写的关于 Fantom 和联合国儿童基金会在某一段时间内收到的资助申请数据的数据分析。我们发现可以使用一些算法自动鉴别资助的有效性。
在这篇文章中,我们将主要探索资助申请背后的隐藏模式。具体来说,我们将尝试使用基础和高级技术,如数据收集 / 挖掘、聚类、半监督学习等,自动确定资助申请的资格。让我们深入探讨吧!
数据预处理#
格式化#
开放数据基金会(ODF)为我们提供了两个资助申请供我们探索:Fantom 资助申请和联合国儿童基金会资助申请。由于这两个申请有不同的字段(例如,Fantom 资助申请有一个previous_funding
字段,而联合国儿童基金会资助申请没有),我们首先将这两个申请格式化为相同的格式。具体来说,我们只需要title
、description
、website
、github_user
和project_github
用于后续分析。
fantom_grants = fantom_grants[["title", "description", "website", "github_user", "project_github"]]
unicef_grants = unicef_grants[["title", "description", "website", "github_user", "project_github"]]
相关性检测#
在申请资助时,标题和描述对于审阅者理解项目及其潜在价值至关重要。因此,标题和描述必须足够清晰,以便理解并提供关于项目的足够信息。在这一小节中,我们将展示如何检测提供的标题和描述的相关性,这可能用于使用机器学习和预训练的大规模自然语言处理(NLP)模型过滤无意义或垃圾申请。观察到项目的描述可能非常长,这对后续分类不利,我们将首先使用摘要生成器将非常长的描述总结为相对较短的描述。在这里,我们将使用 Facebook 训练的bart-large-cnn
模型。bart-large-cnn
基于 BART,是一种用于预训练基于变换器的序列到序列模型的去噪自编码器。bart-large-cnn
的实验结果表明,它在cnn-news
数据集上达到了非常高的准确率。
from transformers import pipeline
# 如果描述超过100个单词,则总结描述以过滤无意义的句子
if len(description.split()) > 100:
# 如果描述超过512个单词,则取前512个单词
if len(description.split()) > 512:
description = ' '.join(description.split()[:512])
description = summarizer(description, max_length=100, min_length=0, do_sample=False)
然后,我们将使用另一个模型来确定标题和描述之间的相关性。考虑到评估资助申请中项目描述质量的任务可以视为评估对话中响应质量的任务,我们将使用tinkoff-ai
训练的response-quality-classifier-large
模型。为了将我们的任务转换为响应质量评估任务,我们需要使用项目标题和项目描述构建查询:
[CLS]你的项目{PROJECT_TITLE}是关于什么的?
[RESPONSE_TOKEN]{PROJECT_DESCRIPTION}
因此,通过将上述查询输入模型,模型将根据问题确定PROJECT_DESCRIPTION
的相关性。评估的代码如下所示:
from transformers import AutoTokenizer, AutoModelForSequenceClassification
rel_tokenizer = AutoTokenizer.from_pretrained("tinkoff-ai/response-quality-classifier-large")
rel_model = AutoModelForSequenceClassification.from_pretrained("tinkoff-ai/response-quality-classifier-large")
query = f"""[CLS]你的项目{title}是关于什么的?
[RESPONSE_TOKEN]{description}"""
inputs = rel_tokenizer(query, max_length=128, add_special_tokens=False, truncation=True, return_tensors='pt')
with torch.inference_mode():
logits = rel_model(**inputs).logits
probas = torch.sigmoid(logits)[0].cpu().detach().numpy()
relevance, _ = probas
在我们的实现中,相关性 < 0.1 的项目将被直接拒绝,而无需进一步考虑。
网站检查#
我们使用WHOIS
检查网站是否可连接,并查询网站的信息。
import whois
def get_website_whois_info(urls):
"""
查询给定网址的whois信息
:param url: 网站的url
:return: 网站的whois信息
"""
results = []
for url in urls:
try:
whois_data = whois.whois(url)
results.append(whois_data)
except whois.parser.PywhoisError:
results.append(None)
return results
在获取提供网站的 whois 信息后,我们将检查该网站是否:
- 已经过期。
- 在 90 天内将过期。
- 在 1 年内将过期。
此外,注意到一些项目使用外部链接作为其网站(例如 github.io、twitter.com、youtube.com、notion.so 等),我们使用一个简单的分类器,通过模式匹配来确定提供的网站是否是外部的。
Github 检查#
对于个人,我们将检查他(她)在过去一年的贡献。这一指标反映了他(她)在开源社区的活动。
from bs4 import BeautifulSoup
import requests
GITHUB_URL = 'https://github.com/'
def get_github_user_contributions(usernames):
"""
获取github用户过去一年的公共贡献。
:param usernames: 一个字符串或一系列github用户名。
"""
contributions = {'users': [], 'total': 0}
if isinstance(usernames, str):
usernames = [usernames]
for username in usernames:
# 如果用户名是以'https://'开头的url,则提取用户名。
if username.startswith('https://') or username.startswith('http://'):
username = username.split('/')[3]
response = requests.get('{0}{1}'.format(GITHUB_URL, username))
if not response.ok:
contributions['users'].append({username: dict(total=0)})
continue
bs = BeautifulSoup(response.content, "html.parser")
total = bs.find('div', {'class': 'js-yearly-contributions'}).findNext('h2')
contributions['users'].append({username: dict(total=int(total.text.split()[0].replace(',', '')))})
contributions['total'] += int(total.text.split()[0].replace(',', ''))
return json.dumps(contributions, indent=4)
对于组织,我们将检查过去一年所有公共存储库的提交总数。这一指标反映了该组织在开源社区的活动。
import datetime
from github import Github
github = Github()
def get_github_org_contributions(orgs):
"""
获取github组织过去一年的公共贡献。
:param orgs: 一个字符串或一系列github组织。
"""
contributions = {'orgs': [], 'total': 0}
if isinstance(orgs, str):
orgs = [orgs]
for org in orgs:
all_repos = github.get_organization(org).get_repos()
total_commits = 0
for repo in all_repos:
commits = repo.get_commits(since=datetime.datetime.now() - datetime.timedelta(days=365))
total_commits += commits.totalCount
contributions['orgs'].append({org: dict(total=total_commits)})
contributions['total'] += total_commits
return json.dumps(contributions, indent=4)
聚类#
在数据预处理之后,剩下 7 个字段用于后续分析:
"github_user_contributions",
"project_github_contributions",
"website_expired",
"website_expired_in_90_days",
"website_expired_in_1_year",
"external_url",
"desc_relevance"
也就是说,我们的数据集目前有 7 个维度,这在我们生活的三维世界中很难分析。因此,在聚类之前,让我们先降低数据集的维度。我们首先使用MinMaxScaler()
对数据集进行归一化:
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
grants_scaled = scaler.fit_transform(grants)
然后,为了探索可能的模式,让我们使用T-SNE
降低维度,因为它目前是最先进的技术之一。
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.manifold import TSNE
tsne = TSNE(n_components=2, verbose=True)
grants_reduced = tsne.fit_transform(grants_scaled)
# 可视化
sns.scatterplot(x=grants_reduced[:, 0], y=grants_reduced[:, 1])
plt.show()
从图中可以看出,资助被分为不同的组。接下来,我们使用DBSCAN
算法进行聚类。这里我们使用DBSCAN
算法而不是K-MEANS
,因为DBSCAN
是一种基于密度的聚类算法,可以检测离群点,并且不需要指定超参数k
。
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.cluster import DBSCAN
# DBSCAN聚类
db = DBSCAN(eps=1.0, min_samples=5).fit(grants_reduced)
labels = db.labels_
# 可视化
sns.scatterplot(x=grants_reduced[:, 0], y=grants_reduced[:, 1],
hue=labels, palette=sns.color_palette("hls", len(set(labels))))
plt.show()
如图所示,每个资助都被正确分配了一个标签,指示其聚类。请注意,-1
标签意味着DBSCAN
算法认为该资助是离群点。显然,聚类的结果验证了我们处理过的数据集的有效性,并且有一些隐藏模式等待我们去发现。
定义 “合格”#
现在我们已经知道不同申请之间的关系(如聚类所示),我们希望为聚类中的组分配标签,以便我们可以通过计算一个申请属于哪个组来对其进行分类。实际上,如果我们的分类器能够告诉我们申请是否合格,那就足够了,因此标签可以简单地表示资助是否合格的布尔值。为了实现这一点,我们需要首先定义什么是合格。因此,在这一节中,我们将手动收集一小部分合格项目(可能是 10 个项目),稍后我们将使用半监督学习自动学习如何对我们数据集中的资助申请进行分类。
十个知名项目,包括 Uniswap、AAVE、Curve、Gnosis Safe 等,被手动收集作为正数据。数据集如下所示,以供重现:
在使用与 Fantom 和联合国儿童基金会数据集完全相同的方式预处理正数据集后,所有数据样本的可视化如图所示。在这里,标签 0 表示未标记数据,标签 1 表示正数据。
如图所示,正数据显然比其他组更接近某些组,表明它们可以帮助机器学习模型理解哪些申请应该是合格的,哪些不应该。
从正数据和未标记数据中学习(PU 学习)#
在这一节中,我们将训练一个简单的分类器,使用一些正数据对未标记数据进行分类。实际上,已经提出了许多算法用于从大量未标记数据和少量正数据中学习。然而,由于我们在降维后已经有了相当不错的聚类,我们实际上可以使用标签传播和多数投票来构建自己的简单分类器。具体来说,我们首先计算每个正样本与每个聚类的中心点之间的 L2 距离。然后,每个正样本将投票给与其距离最小的聚类。最后,我们应用 top-k 投票结果以检索未标记数据的估计。实现如下:
# 计算每个聚类的中心
centers = {}
for label in set(labels):
if label == -1:
continue
centers[label] = np.mean(X[labels == label], axis=0)
votes = {}
positives = X[:10]
for positive in positives:
dists = {}
for label, point in centers.items():
dist = np.linalg.norm(positive - point)
dists[label] = dist
min_label = min(dists, key=dists.get)
if min_label not in votes:
votes[min_label] = 1
else:
votes[min_label] += 1
# top-k投票
eligible_clusters = sorted(votes, key=votes.get, reverse=True)[:4]
new_labels = [int(label in eligible_clusters) for label in labels]
估计结果可视化如下图,其中标签 0 表示我们手动收集的正数据,标签 1 表示从未标记数据中估计的负申请,标签 2 表示从未标记数据中估计的正申请。
估计的负申请如下所示。可以看出,项目 114、116 和 117 显然是测试申请,我们的算法成功地将它们分类为负申请,验证了我们提出的算法的有效性。此外,在手动检查其他估计的负项目后,大多数项目的 GitHub 活动较低且网站不正式 / 外部,这表明资助审阅者可能需要更加关注它们。
id title ... desc_relevance
2 Just Brew It DAO ... 0.762122
9 The Sterling project ... 0.640220
23 Validator Node Encouragement Fund ... 0.668328
29 Mowse ... 0.910632
30 Crypto Policy DAO ... 0.814382
31 Racing Snails ... 0.313622
48 A Fantoman & Fantomonstre ... 0.470715
49 Grey Market ... 0.890565
57 ALL IN FINANCE ... 0.584226
58 Planet Keeper ... 0.714447
64 Depeg Finance ... 0.612105
69 Fantom Nobles ... 0.407439
74 Fantom Italia ... 0.701125
100 inDemniFi ... 0.657283
101 JPGs Against Humanity ... 0.873490
111 Pixframe Studios - Transforming Education ... 0.888190
114 Daniele's Test Project ... 0.060001
116 NaN ... 0.000000
117 Test ... 0.230208