简体   繁体   中英

How can I simplify repetitive if-elif statements in my grading system function?

The goal is to build a program to convert scores from a '0 to 1' system to an 'F to A' system:

  • If score >= 0.9 would print 'A'
  • If score >= 0.8 would print 'B'
  • 0.7, C
  • 0.6, D
  • And any value below that point, print F

This is the way to build it and it works on the program, but it's somewhat repetitive:

if scr >= 0.9:
    print('A')
elif scr >= 0.8:
    print('B')
elif scr >= 0.7:
    print('C')
elif scr >= 0.6:
    print('D')
else:
    print('F')

I would like to know if there is a way to build a function so that the compound statements wouldn't be as repetitive.

I'm a total beginner, but would something in the lines of:

def convertgrade(scr, numgrd, ltrgrd):
    if scr >= numgrd:
        return ltrgrd
    if scr < numgrd:
        return ltrgrd

be possible?

The intention here is that later we can call it by only passing the scr, numbergrade and letter grade as arguments:

convertgrade(scr, 0.9, 'A')
convertgrade(scr, 0.8, 'B')
convertgrade(scr, 0.7, 'C')
convertgrade(scr, 0.6, 'D')
convertgrade(scr, 0.6, 'F')

If it would be possible to pass fewer arguments, it would be even better.

You can use the bisect module to do a numeric table lookup:

from bisect import bisect 

def grade(score, breakpoints=(60, 70, 80, 90), grades='FDCBA'):
     i = bisect(breakpoints, score)
     return grades[i]

>>> [grade(score) for score in [33, 99, 77, 70, 89, 90, 100]]
['F', 'A', 'C', 'C', 'B', 'A', 'A']

You can do something along these lines:

# if used repeatedly, it's better to declare outside of function and reuse
# grades = list(zip('ABCD', (.9, .8, .7, .6)))

def grade(score):
    grades = zip('ABCD', (.9, .8, .7, .6))
    return next((grade for grade, limit in grades if score >= limit), 'F')

>>> grade(1)
'A'
>>> grade(0.85)
'B'
>>> grade(0.55)
'F'

This uses next with a default argument on a generator over the score-grade pairs created by zip . It is virtually the exact equivalent of your loop approach.

You could assign each grade a threshold value:

grades = {"A": 0.9, "B": 0.8, "C": 0.7, "D": 0.6, "E": 0.5}

def convert_grade(scr):
    for ltrgrd, numgrd in grades.items():
        if scr >= numgrd:
            return ltrgrd
    return "F"

In this specific case you don't need external modules or generators. Some basic math is enough (and faster)!

grades = ["A", "B", "C", "D", "F"]

def convert_score(score):
    return grades[-max(int(score * 10) - 5, 0) - 1]

# Examples:
print(convert_grade(0.61)) # "D"
print(convert_grade(0.37)) # "F"
print(convert_grade(0.94)) # "A"

You could use numpy.searchsorted , which additionally gives you this nice option of processing multiple scores in a single call:

import numpy as np

grades = np.array(['F', 'D', 'C', 'B', 'A'])
thresholds = np.arange(0.6, 1, 0.1)

scores = np.array([0.75, 0.83, 0.34, 0.9])
grades[np.searchsorted(thresholds, scores)]  # output: ['C', 'B', 'F', 'A']

You can use np.select from numpy library for multiple conditions:

>> x = np.array([0.9,0.8,0.7,0.6,0.5])

>> conditions  = [ x >= 0.9,  x >= 0.8, x >= 0.7, x >= 0.6]
>> choices     = ['A','B','C','D']

>> np.select(conditions, choices, default='F')
>> array(['A', 'B', 'C', 'D', 'F'], dtype='<U1')

I've got a simple idea to solve this:

def convert_grade(numgrd):
    number = min(9, int(numgrd * 10))
    number = number if number >= 6 else 4
    return chr(74 - number)

Now,

print(convert_grade(.95))  # --> A 
print(convert_grade(.9))  # --> A
print(convert_grade(.4))  # --> F
print(convert_grade(.2))  # --> F

You provided a simple case. However if your logic is getting more complicated, you may need a rules engine to handle the chaos.

You can try Sauron Rule engine or find some Python rules engines from PYPI.

>>> grade = lambda score:'FFFFFFDCBAA'[int(score*100)//10]
>>> grade(0.8)
'B'

I am not adding much to the party, except for some timing on the most noteworthy solutions:

import bisect


def grade_bis(score, thresholds=(0.9, 0.8, 0.7, 0.6), grades="ABCDF"):
    i = bisect.bisect(thresholds[::-1], score)
    return grades[-i - 1]
def grade_gen(score, thresholds=(0.9, 0.8, 0.7, 0.6), grades="ABCDF"):
    return next((
        grade
        for grade, threshold in zip(grades, thresholds)
        if score >= threshold), grades[-1])
  • using a enumeration-based linear search (adapted from @nico's answer )
def grade_enu(score, thresholds=(0.9, 0.8, 0.7, 0.6), grades="ABCDF"):
    for i, threshold in enumerate(thresholds):
        if score >= threshold:
            return grades[i]
    return grades[-1]
  • using basic algebra -- although this does not generalize to arbitrary breakpoints, while the above do (based on @RiccardoBucco's answer ):
def grade_alg(score, grades="ABCDF"):
    return grades[-max(int(score * 10) - 5, 0) - 1]
  • using a chain of if - elif - else (essentially, OP's approach, which also does not generalize):
def grade_iff(score):
    if score >= 0.9:
        return "A"
    elif score >= 0.8:
        return "B"
    elif score >= 0.7:
        return "C"
    elif score >= 0.6:
        return "D"
    else:
        return "F"

They all give the same result:

import random
random.seed(2)
scores = [round(random.random(), 2) for _ in range(10)]
print(scores)
# [0.96, 0.95, 0.06, 0.08, 0.84, 0.74, 0.67, 0.31, 0.61, 0.61]

funcs = grade_bis, grade_gen, grade_enu, grade_alg
for func in funcs:
    print(f"{func.__name__:>12}", list(map(func, scores)))
#    grade_bis ['A', 'A', 'F', 'F', 'B', 'C', 'D', 'F', 'D', 'D']
#    grade_gen ['A', 'A', 'F', 'F', 'B', 'C', 'D', 'F', 'D', 'D']
#    grade_enu ['A', 'A', 'F', 'F', 'B', 'C', 'D', 'F', 'D', 'D']
#    grade_alg ['A', 'A', 'F', 'F', 'B', 'C', 'D', 'F', 'D', 'D']
#    grade_iff ['A', 'A', 'F', 'F', 'B', 'C', 'D', 'F', 'D', 'D']

with the following timings (on n = 100000 repetitions into a list ):

n = 100000
scores = [random.random() for _ in range(n)]
base = list(map(funcs[0], scores))
for func in funcs:
    res = list(map(func, scores))
    is_good = base == res
    print(f"{func.__name__:>12}  {is_good}  ", end="")
    %timeit -n 4 -r 4 list(map(func, scores))
#    grade_bis  True  4 loops, best of 4: 46.1 ms per loop
#    grade_gen  True  4 loops, best of 4: 96.6 ms per loop
#    grade_enu  True  4 loops, best of 4: 54.4 ms per loop
#    grade_alg  True  4 loops, best of 4: 47.3 ms per loop
#    grade_iff  True  4 loops, best of 4: 17.1 ms per loop

indicating that the OP's approach is the fastest by far, and, among those that can be generalized to arbitrary thresholds, the bisect -based approach is the fastest in the current settings.


As a function of the number of thresholds

Given that the linear search should be faster than binary search for very small inputs, it is interesting both to see where is the break-even point and to confirm that this application of binary search grows sub-linearly (logarithmically).

To do so, a benchmark as a function of the number of thresholds is provided (excluding thos:

import string


n = 1000
m = len(string.ascii_uppercase)
scores = [random.random() for _ in range(n)]

timings = {}
for i in range(2, m + 1):
    breakpoints = [round(1 - x / i, 2) for x in range(1, i)]
    grades = string.ascii_uppercase[:i]
    print(grades)
    timings[i] = []
    base = [funcs[0](score, breakpoints, grades) for score in scores]
    for func in funcs[:-2]:
        res = [func(score, breakpoints, grades) for score in scores]
        is_good = base == res
        timed = %timeit -r 16 -n 16 -q -o [func(score, breakpoints, grades) for score in scores]
        timing = timed.best * 1e3
        timings[i].append(timing if is_good else None)
        print(f"{func.__name__:>24}  {is_good}  {timing:10.3f} ms")

which can be plotted with the following:

import pandas as pd
import matplotlib.pyplot as plt


df = pd.DataFrame(data=timings, index=[func.__name__ for func in funcs[:-2]]).transpose()
df.plot(marker='o', xlabel='Input size / #', ylabel='Best timing / µs', figsize=(6, 4))
fig = plt.gcf()
fig.patch.set_facecolor('white')

to produce:

bm

suggesting that the break-even point is around 5 , also confirming the linear growth of grade_gen() and grade_enu() , and the sub-linear growth of grade_bis() .


NumPy-based approaches

Approaches that are capable of working with NumPy should be evaluated separately, as they take different inputs and are capable of processing arrays in a vectorized fashion.

You could also use a recursive approach:

grade_mapping = list(zip((0.9, 0.8, 0.7, 0.6, 0), 'ABCDF'))
def get_grade(score, index = 0):
    if score >= grade_mapping[index][0]:
        return(grade_mapping[index][1])
    else:
        return(get_grade(score, index = index + 1))

>>> print([get_grade(score) for score in [0, 0.59, 0.6, 0.69, 0.79, 0.89, 0.9, 1]])
['F', 'F', 'D', 'D', 'C', 'B', 'A', 'A']

Here are some more succinct and less understandable approaches:

The first solution requires the use of the floor function from the math library.

from math import floor
def grade(mark):
    return ["D", "C", "B", "A"][min(floor(10 * mark - 6), 3)] if mark >= 0.6 else "F"

And if for some reason importing the math library is bothering you. You could use a work around for the floor function:

def grade(mark):
    return ["D", "C", "B", "A"][min(int(10 * mark - 6) // 1, 3)] if mark >= 0.6 else "F"

These are a bit complicated and I would advice against using them unless you understand what is going on. They are specific solutions that take advantage of the fact that the increments in grades are 0.1 meaning that using an increment other than 0.1 would probably not work using this technique. It also doesn't have an easy interface for mapping marks to grades. A more general solution such as the one by dawg using bisect is probably more appropriate or schwobaseggl's very clean solution. I'm not really sure why I'm posting this answer but it's just an attempt at solving the problem without any libraries (I'm not trying to say that using libraries is bad) in one line demonstrating the versatile nature of python.

You can use a dict.

Code

def grade(score):
    """Return a letter grade."""
    grades = {100: "A", 90: "A", 80: "B", 70: "C", 60: "D"}
    return grades.get((score // 10) * 10, "F")

Demo

[grade(scr) for scr in [100, 33, 95, 61, 77, 90, 89]]

# ['A', 'F', 'A', 'D', 'C', 'A', 'B']

If scores are actually between 0 and 1, first multiply 100, then lookup the score.

Hope following might help:if scr >= 0.9:print('A')elif 0.9 > scr >= 0.8:print('B')elif 0.8 > scr >= 0.7:Print('C')elif 0.7 scr >= 0.6:print('D')else:print('F')

The goal is to build a program to convert scores from a '0 to 1' system to an 'F to A' system:

  • If score >= 0.9 would print 'A'
  • If score >= 0.8 would print 'B'
  • 0.7, C
  • 0.6, D
  • And any value below that point, print F

This is the way to build it and it works on the program, but it's somewhat repetitive:

if scr >= 0.9:
    print('A')
elif scr >= 0.8:
    print('B')
elif scr >= 0.7:
    print('C')
elif scr >= 0.6:
    print('D')
else:
    print('F')

I would like to know if there is a way to build a function so that the compound statements wouldn't be as repetitive.

I'm a total beginner, but would something in the lines of:

def convertgrade(scr, numgrd, ltrgrd):
    if scr >= numgrd:
        return ltrgrd
    if scr < numgrd:
        return ltrgrd

be possible?

The intention here is that later we can call it by only passing the scr, numbergrade and letter grade as arguments:

convertgrade(scr, 0.9, 'A')
convertgrade(scr, 0.8, 'B')
convertgrade(scr, 0.7, 'C')
convertgrade(scr, 0.6, 'D')
convertgrade(scr, 0.6, 'F')

If it would be possible to pass fewer arguments, it would be even better.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM