作者|Khuyen Tran
编译|VK

数据科学家的Pytest_数据科学

动机

应用不同的python代码来处理notebook中的数据是很有趣的,但是为了使代码具有可复制性,你需要将它们放入函数和类中。将代码放入脚本时,代码可能会因某些函数而中断。那么,如何检查你的功能是否如你所期望的那样工作呢?

例如,我们使用TextBlob创建一个函数来提取文本的情感,TextBlob是一个用于处理文本数据的Python库。我们希望确保它像我们预期的那样工作:如果测试为积极,函数返回一个大于0的值;如果文本为消极,则返回一个小于0的值。

from textblob import TextBlob

def extract_sentiment(text: str):
        '''使用textblob提取情绪。
        	在范围[- 1,1]内'''

        text = TextBlob(text)

        return text.sentiment.polarity

要知道函数是否每次都会返回正确的值,最好的方法是将这些函数应用于不同的示例,看看它是否会产生我们想要的结果。这就是测试的重要性。

一般来说,你应该在数据科学项目中使用测试,因为它允许你:

  • 确保代码按预期工作

  • 检测边缘情况

  • 有信心用改进的代码交换现有代码,而不必担心破坏整个管道

有许多Python工具可用于测试,但最简单的工具是Pytest。

Pytest入门

Pytest是一个框架,它使得用Python编写小测试变得容易。我喜欢pytest,因为它可以帮助我用最少的代码编写测试。如果你不熟悉测试,那么pytest是一个很好的入门工具。

要安装pytest,请运行

pip install -U pytest

要测试上面所示的函数,我们可以简单地创建一个函数,该函数以test_开头,后面跟着我们要测试的函数的名称,即extract_sentiment

#sentiment.py
def extract_sentiment(text: str):
        '''使用textblob提取情绪。
        	在范围[- 1,1]内'''

        text = TextBlob(text)

        return text.sentiment.polarity

def test_extract_sentiment():

    text = "I think today will be a great day"

    sentiment = extract_sentiment(text)

    assert sentiment > 0

在测试函数中,我们将函数extract_sentiment应用于示例文本:“I think today will be a great day”。我们使用assert sentiment > 0来确保情绪是积极的。

就这样!现在我们准备好运行测试了。

如果我们的脚本名是sentiment.py,我们可以运行

pytest sentiment.py

Pytest将遍历我们的脚本并运行以test开头的函数。上面的测试输出如下所示

========================================= test session starts ==========================================
platform linux -- Python 3.8.3, pytest-5.4.2, py-1.8.1, pluggy-0.13.1

collected 1 item
process.py .                                                                                     [100%]

========================================== 1 passed in 0.68s ===========================================

很酷!我们不需要指定要测试哪个函数。只要函数名以test开头,pytest就会检测并执行该函数!我们甚至不需要导入pytest就可以运行pytest

如果测试失败,pytest会产生什么输出?

#sentiment.py

def test_extract_sentiment():

    text = "I think today will be a great day"

    sentiment = extract_sentiment(text)

    assert sentiment < 0
>>> pytest sentiment.py

========================================= test session starts ==========================================
platform linux -- Python 3.8.3, pytest-5.4.2, py-1.8.1, pluggy-0.13.1
collected 1 item

process.py F                                                                                     [100%]
=============================================== FAILURES ===============================================
________________________________________ test_extract_sentiment ________________________________________

def test_extract_sentiment():
    
        text = "I think today will be a great day"
    
        sentiment = extract_sentiment(text)
    
>       assert sentiment < 0
E       assert 0.8 < 0

process.py:17: AssertionError
======================================= short test summary info ========================================
FAILED process.py::test_extract_sentiment - assert 0.8 < 0
========================================== 1 failed in 0.84s ===========================================

从输出可以看出,测试失败是因为函数的情感值为0.8,并且不小于0!我们不仅可以知道我们的函数是否如预期的那样工作,而且还可以知道为什么它不起作用。从这个角度来看,我们知道在哪里修复我们的函数,以实现我们想要的功能。

同一函数的多次测试

我们可以用其他例子来测试我们的函数。新测试函数的名称是什么?

第二个函数的名称可以是test_extract_sentiment_2,如果我们想在带有负面情绪的文本上测试函数,那么它的名称可以是test_extract_sentiment_negative。任何函数名只要以test开头就可以工作

#sentiment.py

def test_extract_sentiment_positive():

    text = "I think today will be a great day"

    sentiment = extract_sentiment(text)

    assert sentiment > 0

def test_extract_sentiment_negative():

    text = "I do not think this will turn out well"

    sentiment = extract_sentiment(text)

    assert sentiment < 0
>>> pytest sentiment.py

========================================= test session starts ==========================================
platform linux -- Python 3.8.3, pytest-5.4.2, py-1.8.1, pluggy-0.13.1
collected 2 items

process.py .F                                                                                    [100%]
=============================================== FAILURES ===============================================
___________________________________ test_extract_sentiment_negative ____________________________________

def test_extract_sentiment_negative():
    
        text = "I do not think this will turn out well"
    
        sentiment = extract_sentiment(text)
    
>       assert sentiment < 0
E       assert 0.0 < 0

process.py:25: AssertionError
======================================= short test summary info ========================================
FAILED process.py::test_extract_sentiment_negative - assert 0.0 < 0
===================================== 1 failed, 1 passed in 0.80s ======================================

从输出中,我们知道一个测试通过,一个测试失败,以及测试失败的原因。我们希望“I do not think this will turn out well”这句话是消极的,但结果却是0。

这有助于我们理解,函数可能不会100%准确;因此,在使用此函数提取文本情感时,我们应该谨慎。

参数化:组合测试

以上2个测试功能用于测试同一功能。有没有办法把两个例子合并成一个测试函数?这时参数化就派上用场了

用样本列表参数化

使用pytest.mark.parametrize(),通过在参数中提供示例列表,我们可以使用不同的示例执行测试。

# sentiment.py

from textblob import TextBlob
import pytest

def extract_sentiment(text: str):
        '''使用textblob提取情绪。
        	在范围[- 1,1]内'''

        text = TextBlob(text)

        return text.sentiment.polarity

testdata = ["I think today will be a great day","I do not think this will turn out well"]

@pytest.mark.parametrize('sample', testdata)
def test_extract_sentiment(sample):

    sentiment = extract_sentiment(sample)

    assert sentiment > 0

在上面的代码中,我们将变量sample分配给一个示例列表,然后将该变量添加到测试函数的参数中。现在每个例子将一次测试一次。

========================== test session starts ===========================
platform linux -- Python 3.8.3, pytest-5.4.2, py-1.8.1, pluggy-0.13.1
collected 2 items

sentiment.py .F                                                    [100%]

================================ FAILURES ================================
_____ test_extract_sentiment[I do not think this will turn out well] _____

sample = 'I do not think this will turn out well'

@pytest.mark.parametrize('sample', testdata)
    def test_extract_sentiment(sample):
    
        sentiment = extract_sentiment(sample)
    
>       assert sentiment > 0
E       assert 0.0 > 0

sentiment.py:19: AssertionError
======================== short test summary info =========================
FAILED sentiment.py::test_extract_sentiment[I do not think this will turn out well]
====================== 1 failed, 1 passed in 0.80s ===================

使用parametrize(),我们可以在once函数中测试两个不同的示例!

使用示例列表和预期输出进行参数化

如果我们期望不同的例子有不同的输出呢?Pytest还允许我们向测试函数的参数添加示例和预期输出!

例如,下面的函数检查文本是否包含特定的单词。

def text_contain_word(word: str, text: str):
    '''检查文本是否包含特定的单词'''
    
    return word in text

如果文本包含单词,则返回True。

如果单词是“duck”,而文本是“There is a duck in this text”,我们期望返回True。

如果单词是‘duck’,而文本是‘There is nothing here’,我们期望返回False。

我们将使用parametrize()而不使用元组列表。

# process.py
import pytest
def text_contain_word(word: str, text: str):
    '''查找文本是否包含特定的单词'''
    
    return word in text

testdata = [
    ('There is a duck in this text',True),
    ('There is nothing here', False)
    ]

@pytest.mark.parametrize('sample, expected_output', testdata)
def test_text_contain_word(sample, expected_output):

    word = 'duck'

    assert text_contain_word(word, sample) == expected_output

函数的参数结构为parametrize('sample,expected_out','testdata),testdata=[(,),(,)

>>> pytest process.py

========================================= test session starts ==========================================
platform linux -- Python 3.8.3, pytest-5.4.2, py-1.8.1, pluggy-0.13.1
plugins: hydra-core-1.0.0, Faker-4.1.1
collected 2 items

process.py ..                                                                                    [100%]

========================================== 2 passed in 0.04s ===========================================

我们的两个测试都通过了!

一次测试一个函数

当脚本中测试函数的数量越来越大时,你可能希望一次测试一个函数而不是多个函数。用pytest很容易,pytest file.py::function_name

testdata = ["I think today will be a great day","I do not think this will turn out well"]

@pytest.mark.parametrize('sample', testdata)
def test_extract_sentiment(sample):

    sentiment = extract_sentiment(sample)

    assert sentiment > 0


testdata = [
    ('There is a duck in this text',True),
    ('There is nothing here', False)
    ]

@pytest.mark.parametrize('sample, expected_output', testdata)
def test_text_contain_word(sample, expected_output):

    word = 'duck'

    assert text_contain_word(word, sample) == expected_output

例如,如果你只想运行test_text_contain_word,请运行

pytest process.py::test_text_contain_word

而pytest只执行我们指定的一个测试!

fixture:使用相同的数据来测试不同的函数

如果我们想用相同的数据来测试不同的函数呢?例如,我们想测试“今Today I found a duck and I am happy”这句话是否包含“duck ”这个词,它的情绪是否是积极的。这是fixture派上用场的时候。

pytest fixture是一种向不同的测试函数提供数据的方法

@pytest.fixture
def example_data():
    return 'Today I found a duck and I am happy'


def test_extract_sentiment(example_data):

    sentiment = extract_sentiment(example_data)

    assert sentiment > 0

def test_text_contain_word(example_data):

    word = 'duck'

    assert text_contain_word(word, example_data) == True

在上面的示例中,我们使用decorator创建了一个示例数据@pytest.fixture在函数example_data的上方。这将把example_data转换成一个值为“Today I found a duck and I am happy”的变量

现在,我们可以使用示例数据作为任何测试的参数!

组织你的项目

最后但并非最不重要的是,当代码变大时,我们可能需要将数据科学函数和测试函数放在两个不同的文件夹中。这将使我们更容易找到每个函数的位置。

test_<name>.py<name>_test.py命名我们的测试函数. Pytest将搜索名称以“test”结尾或以“test”开头的文件,并在该文件中执行名称以“test”开头的函数。这很方便!

有不同的方法来组织你的文件。你可以将我们的数据科学文件和测试文件组织在同一个目录中,也可以在两个不同的目录中组织,一个用于源代码,一个用于测试

方法1:

test_structure_example/
├── process.py
└── test_process.py

方法2:

test_structure_example/
├── src
│   └── process.py
└── tests
    └── test_process.py

由于数据科学函数很可能有多个文件,测试函数有多个文件,所以你可能需要将它们放在不同的目录中,如方法2。

这是2个文件的样子

from textblob import TextBlob

def extract_sentiment(text: str):
        '''使用textblob提取情绪。
        	在范围[- 1,1]内'''

        text = TextBlob(text)

        return text.sentiment.polarity
import sys
import os.path
sys.path.append(
    os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir)))
from src.process import extract_sentiment
import pytest


def test_extract_sentiment():

    text = 'Today I found a duck and I am happy'

    sentiment = extract_sentiment(text)

    assert sentiment > 0

简单地说添加sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir)))可以从父目录导入函数。

在根目录(test_structure_example/)下,运行pytest tests/test_process.py或者运行在test_structure_example/tests目录下的pytest test_process.py

========================== test session starts ===========================
platform linux -- Python 3.8.3, pytest-5.4.2, py-1.8.1, pluggy-0.13.1
collected 1 item

tests/test_process.py .                                            [100%]

=========================== 1 passed in 0.69s ============================

很酷!

结论

你刚刚了解了pytest。我希望本文能很好地概述为什么测试很重要,以及如何将测试与pytest结合到数据科学项目中。通过测试,你不仅可以知道你的函数是否按预期工作,而且还可以自信地使用不同的工具或不同的代码结构来切换现有代码。

本文的源代码可以在这里找到:

https://github.com/khuyentran1401/Data-science/tree/master/data_science_tools/pytest

我喜欢写一些基本的数据科学概念,玩不同的算法和数据科学工具。