数据科学界的重点是减少数据收集,清理和组织所涉及的复杂性和时间。本文讨论了如何使用软件工程中的面向对象设计技术来减少编码开销并创建健壮,可重用的数据采集和清理系统。我将概述面向对象的设计,并演示使用这些技术从Python中的Web API获取和清理数据的示例。你可以在Github上找到这篇文章的Jupyter Notebook。
面向对象设计
许多现代软件工程利用面向对象设计(OOD)的原理(也称为面向对象编程(OOP))来创建易于扩展,测试和维护的代码库。顾名思义,这种编程范式集中在根据对象思考代码。对象封装与特定实体相关的数据,属性和方法。使用类定义对象,然后可以将其实例化以创建多个对象,称为类的实例。
我们将使用娱乐信息数据库API(一种用于在美国寻找娱乐机会的JSON REST API)来完成类设计的一些示例。
如果您不熟悉Python中的面向对象设计,那么Python Tutorial有一些很好的入门信息。当我们浏览代码示例时,如果您对Python类设计的细节感兴趣,例如“这是什么”self“参数?”,我将在高阶讨论事情,请查看教程更多信息。
使用类
由于我们已经介绍了对象的基本概念,让我们考虑创建一个对象来封装数据源,回想一下对象有方法,属性和数据。。要开始识别数据源类的潜在方法,请考虑与数据源交互时使用的典型活动。一旦我们获取了数据,我们可能希望将其清理并格式化以供其他活动使用 - 可视化,分析或特征转化。从面向对象的角度来看,方法'extract'和'clean'可以涵盖这些活动。数据源对象的属性将包括用于保存我们已提取和清理的数据的构造,以及可能的一些标识信息,例如请求URL和我们传递给它的参数。我们的数据源对象的数据将是URL,参数和包含提取和清理数据的DataFrame的值。这UML类图,名为RidbData。UML图提供了类结构和关系的直观表示。顶部显示了类名,中间包含属性名称,底部包含方法名称。通常,方法将伴随它们各自的参数,但为了简洁起见,这里省略了这些参数。
让我们看一些用于创建此数据源对象的Python代码。我们将逐步介绍以下代码的每个部分:
import pandas as pd
import requests
import json
from pandas.io.json import json_normalize
import config
import numpy as np
class RidbData():
def __init__(self, name, endpoint, url_params):
self.df = pd.DataFrame()
self.endpoint = endpoint
self.url_params = url_params
self.name = name
def clean(self) :
# by replacing '' with np.NaN we can use dropna to remove rows missing
# required data, like lat/longs
self.df = self.df.replace('', np.nan)
# normalize column names for lat and long. i.e. can be
# FacilityLatitude or RecAreaLatitude
self.df.columns = self.df.columns.str.replace('.*Latitude', 'Latitude')
self.df.columns = self.df.columns.str.replace('.*Longitude', 'Longitude')
self.df = self.df.dropna(subset=['Latitude','Longitude'])
def extract(self):
request_url = self.endpoint
response = requests.get(url=self.endpoint,params=self.url_params)
data = json.loads(response.text)
self.df = json_normalize(data['RECDATA'])
要创建对象,我们需要一个构造函数方法init。构造函数可用于设置对象的属性并执行任何初始化例程。RIDB有各种不同的end point,因此我们必须指定在'endpoint'参数中创建RidbData对象时要查询的end point,以及我们需要设置的任何url参数,例如RIDB API密钥中的url_params参数。name属性将帮助我们稍后在处理多个RidbData对象时识别此对象。
在我们的提取方法中,我们将查询end point并将JSON响应加载到DataFrame属性'df'中。我们有一个'clean'方法来插入NaN代替空字符串并删除任何没有纬度/经度值的条目,因为我们对没有位置的设施不感兴趣。
为何对象?
让我们退后一步。此时您可能想知道为什么我们编写所有代码而不是简单地创建一个函数:
def get_ridb_data(endpoint,url_params):
response = requests.get(url = endpoint, params = url_params)
data = json.loads(response.text)
df = json_normalize(data['RECDATA'])
df = df.replace('', np.nan)
df.columns = df.columns.str.replace('.*Latitude', 'Latitude')
df.columns = df.columns.str.replace('.*Longitude', 'Longitude')
df = df.dropna(subset=['Latitude','Longitude'])
return df
我很高兴你问!
如果所有RIDB端点具有相同的响应和端点配置,那么这样的函数将正常工作。考虑使用面向对象技术的时候是你发现自己编写了许多专门的函数和'if'语句,以便对特殊情况的代码进行小的调整。例如,当我们从设施端点获取数据时,我们想要删除任何没有纬度和经度的数据,而当我们查询媒体端点时,我们决定只捕获图像数据:
def get_ridb_data(endpoint,url_params):
response = requests.get(url = endpoint, params = url_params)
data = json.loads(response.text)
df = json_normalize(data['RECDATA'])
df = df.replace('', np.nan)
df.columns = df.columns.str.replace('.*Latitude', 'Latitude')
df.columns = df.columns.str.replace('.*Longitude', 'Longitude')
df = df.dropna(subset=['Latitude','Longitude'])
return df
def get_ridb_facility_media(endpoint, url_params):
# endpoint = https://ridb.recreation.gov/api/v1/facilities/facilityID/media/
response = requests.get(url = endpoint, params = url_params)
data = json.loads(response.text)
df = json_normalize(data['RECDATA'])
df = df[df['MediaType'] == 'Image']
return df
请注意,我们在两个函数之间有一些相同的行:
response = requests.get(url = endpoint, params = url_params)
data = json.loads(response.text)
df = json_normalize(data['RECDATA'])
编程中的另一个最佳实践称为DRY原则 - Don’t Repeat Yourself。你可能会说“Pfft!它只有三行代码!“但是如果RIDB将其响应记录名称从“RECDATA”更改为“RECDAT”怎么办?然后,您必须在代码中跟踪“RECDATA”的每个实例并替换它。另外,考虑三行是'get_ridb_facility_media'方法代码的75%。
让我们看一下使用上面创建的RidbData对象查询这两个端点。对于设施端点,我们已经准备好了。只需插入我们的端点,对象方法将处理其余的事情:
facilities = RidbData('facilities_name', 'https://ridb.recreation.gov/api/v1/facilities', dict(apiKey = 'MY_RIDB_API_KEY'))
facilities.extract()
facilities.df.head()
这是运行extract()后数据的快照。请注意,我们有一些空白行将填充NaN:
运行clean()方法并检查DataFrame:
facilities.clean()
facilities.df.head()
将填充NaN,并且FacilityLatitude列现在名为Latitude。
根据端点,RIDB将为纬度和经度提供不同的前缀。
通过将其清除到简单的纬度和经度,我们正在标准化下游分析的数据集。
然后,我们可以创建此对象的其他实例,以获取和清除任何类似端点的数据,例如娱乐区域:
recareas = RidbData('recareas_name','https//ridb.recreation.gov/api/v1/recareas', dict(apiKey='MY_RIDB_API_KEY'))
recareas.extract()
recareas.clean()
recareas.df.head()
看一下上面的recareas数据,我们可以确认列名更改和NaN替换。
现在,如果RIDB API更改其“RECDATA”记录名称,我们只需在一个位置更新代码:RidbData类。以下是我们生成的RidbData类的两个实例。在UML中,类实例用顶部显示“实例名称:类名”,中间部分显示属性值。
我们将解决接下来媒体端点所需的不同“clean()”方法。
扩展类
OOD的原则之一是开放封闭原则(open closed principle):类应该对扩展是开放的,而对修改是封闭的。这意味着一旦一个类完成,测试并验证按预期工作,我们希望将它放在一边。每当你触摸一段代码就会产生新错误的可能性,使用开放/封闭原则我们可以减少错误的可能性,并且还保证一个类可以安全地扩展和使用,因为它在将来不会被更改。
那么我们如何修改现有类的功能呢?在我们的示例中,我们有一个媒体端点,它需要一个不同于我们在RidbData类中编码的方法。我们可以扩展RidbData类并提供一个新的干净方法,同时继承构造函数和提取方法的功能。
class RidbMediaData(RidbData):
def clean(self):
self.df = self.df[self.df['MediaType'] == 'Image']
当我们创建新类RidbMediaData时,我们将RidbData类传递给类定义。这表明RidbMediaData是基类RidbData的派生类。这主要是为了告诉你这个结构周围使用的语言;关键点是RidbMediaData继承了RidbData类的方法和属性,因此它不必实现init构造函数方法或提取方法 - 它将从RidbData获取这些实现。您需要在派生类中实现的唯一方法是与基类不同的方法或属性。
使用我们新的RidbMediaData类,我们可以获得FacilityID 20006的媒体:
facility200006_media = RidbMediaData('facility10media',
'https://ridb.recreation.gov/api/v1/facilities/200006/media',
dict(apiKey = 'MY_RIDB_API_KEY'))
facility200006_media.extract()
facility200006_media.clean()
facility200006_media.df
媒体端点返回EntityID,与FacilityID相同。我们为FacilityID 200006检索了一张图片。这很不错,但是如果我们想要获得几个设施的媒体怎么办?我们应该为每个设施创建一个新对象吗?这似乎不是对资源的良好利用。相反,我们还可以在RidbMediaData对象中提供一个新的实现提取函数,以循环访问FacilityID列表:
class RidbMediaData(RidbData):
def clean(self) :
self.df = self.df[self.df['MediaType'] == 'Image']
def extract(self):
request_url = self.endpoint
for index, param_set in self.url_params.iterrows():
facility_id = param_set['facilityID']
req_url = self.endpoint + str(facility_id) + "/media"
response = requests.get(url=req_url,params=dict(apiKey=param_set['apiKey']))
data = json.loads(response.text)
# append new records to self.df if any exist
if data['RECDATA']:
new_entry = json_normalize(data['RECDATA'])
self.df = self.df.append(new_entry)
要使用extract一次从多个工具获取媒体,我们必须对构造函数参数'endpoint'和'url_params'进行一些更改。对于端点,我们将传递设施端点并附加facilityID和“/ media”以创建每个设施的地址。url_params将成为每个工具的API密钥/ facilityID对的DataFrame:
media_url = 'https://ridb.recreation.gov/api/v1/facilities/'
media_params = pd.DataFrame({
'apiKey':config.API_KEY,
'facilityID':[200001, 200002, 200003, 200004, 200005, 200006, 200007, 200008]
})
ridb_media = RidbMediaData('media', media_url, media_params)
ridb_media.extract()
ridb_media.clean()
ridb_media.df.head()
以上合起来
我们现在有两个对象可用于从RIDB API中提取和清理数据。除了通过继承减少重复代码的好处之外,我们还为所有这些数据源提供了统一的接口。我们可以利用这个来创建一个双线数据提取管道!首先让我们设置我们的对象和端点。
facilities_endpoint = 'https://ridb.recreation.gov/api/v1/facilities/'
recareas_endpoint = 'https://ridb.recreation.gov/api/v1/recareas'
key_dict = dict(apiKey = config.API_KEY)
facilities = RidbData('facilities', facilities_endpoint, key_dict)
recareas = RidbData('recareas', recareas_endpoint, key_dict)
facility_media = RidbMediaData('facilitymedia', facilities_endpoint, media_params)
ridb_data = [facilities,recareas,facility_media]
现在真的很整洁;因为我们的对象都有相同的接口,我们可以用两行来提取和清理所有这些数据:
list(map(lambda x: x.extract(), ridb_data))
list(map(lambda x: x.clean(), ridb_data))
您现在可以检查ridb_data列表中每个对象的已清理数据,即:
facilities.df.head()
Give it a go for yourself!
总结
我们已经看到了OOD范例可以为我们提供可扩展,可共享的数据分析代码的一些方法。一些关键的要点:
- 通过继承,不同的对象可以共享相同的代码,从而降低通过功能更改引入错误的可能性,并使我们能够遵循DRY原则。
- 通过使用关联方法将数据封装在对象中,我们提供了对数据形状及其经历的操作的隐式保证。这对于在功能开发期间跟踪数据操作尤其重要。
- 通过OOD原则创建统一的接口可以帮助我们简化下游操作。
当您编写代码时,请留意这些“代码气味”,以确定OOD提供帮助的潜在机会:
- 通过轻微调整重复自己以适应数据源之间的差异:
- 只是不同的URL,数据库连接字符串或文件名?一个参数化的功能可能很适合你。
- 发现自己在提取代码中写了很多“if”语句?可能是时候重构并考虑使用类
- 在垂直堆栈中查找类似的功能
- 我们查看了用于处理不同数据源的过程,并确定了用于清理和获取数据的类似功能。如果你能在代码中找出这样的相似之处,那么OOD可能有所帮助。
我希望您发现此介绍有助于思考如何组织数据分析代码以提高效率和稳健性。OOD还可用于数据科学工作的其他许多方式,包括使用抽象基类进行接口定义,通过继承编写健壮的Web scraper,以及通过封装功能开发来简化机器学习原型。