In this project, I've developed an Advanced Grade Calculator that not only calculates the average grade but also accounts for credit hours and predicts future performance based on historical grade trends. This tool uses an object-oriented approach, allowing for more robust data management and complex computational capabilities.
# Advanced Grade Calculator
class Course:
def __init__(self, name, credits):
self.name = name
self.credits = credits
self.grades = []
def add_grade(self, grade):
self.grades.append(grade)
def average_grade(self):
weight = {'A+': 4.3, 'A': 4.0, 'A-': 3.7, 'B+': 3.3, 'B': 3.0, 'B-': 2.7,
'C+': 2.3, 'C': 2.0, 'C-': 1.7, 'D': 1.0, 'F': 0.0}
total = sum(weight[grade] for grade in self.grades)
return total / len(self.grades) if self.grades else 0
class Student:
def __init__(self, name):
self.name = name
self.courses = {}
def add_course(self, course):
self.courses[course.name] = course
def calculate_gpa(self):
total_weighted_grade = 0
total_credits = 0
for course in self.courses.values():
course_avg = course.average_grade()
total_weighted_grade += course_avg * course.credits
total_credits += course.credits
return total_weighted_grade / total_credits if total_credits else 0
def predict_performance(self):
# Basic prediction logic based on improvement trends
improvements = []
for course in self.courses.values():
if len(course.grades) > 1:
improvement = course.grades[-1] > course.grades[-2]
improvements.append(improvement)
prediction = "Likely to improve" if sum(improvements) / len(improvements) > 0.5 else "No significant change expected"
return prediction
# Example usage:
student = Student("John Doe")
math = Course("Calculus", 3)
math.add_grade('B')
math.add_grade('A-')
student.add_course(math)
physics = Course("Physics", 4)
physics.add_grade('B+')
student.add_course(physics)
print(f"Calculated GPA: {student.calculate_gpa():.2f}")
print("Performance Prediction:", student.predict_performance())
This script introduces classes for both courses and students, where courses manage their own grades and credits, and the student class manages multiple courses. The calculate_gpa
method in the Student class
computes a weighted average based on credit hours. The predict_performance
method provides a simple prediction on whether the student’s grades are likely to improve based on their past performance trend.
2 Answers 2
This codebase doesn't contain much documentation, but perhaps it is simple enough and self-explanatory enough that it's not really in need of much. Given that, type annotations on signatures and on empty container declarations would be most welcome, as an aid to the Gentle Reader. Plus mypy would then be able to help check your work as you're developing.
appropriate identifiers
The names we choose really matter, they affect how we reason about code.
weight = {'A+': 4.3, 'A': 4.0, ...
These figures are not weights, but points
used for computing a GPA, a grade point average.
In particular, the average_grade()
routine
does not use the concept of WAM, weighted average mark,
and it does not incorporate weighting according to
advanced course status or number of credit hours.
In contrast, the total_weighted_grade
we see further down
is a terrific identifier.
docstring
def predict_performance(self):
# Basic prediction logic based on improvement trends
Turn that comment into a proper """docstring""", please.
Also, consider putting the magic threshold 0.5 up in the signature as a default kwarg, to flag it as arbitrary, and so caller can specify an alternate threshold if desired.
prediction = "Likely to improve" if sum(improvements) / len(improvements) > 0.5 else "No significant change expected"
linear regression
predict_performance()
passed up an opportunity to
use more sophisticated techniques to predict the magnitude
of a likely improvement.
Or various statistical tests, such as Student's t-test, might be employed: for an individual learner, across the whole population taking a course, or everyone enrolled at the school.
main guard
Thank you for illustrating the example usage. Consider putting that
code within def main():
or def example_usage():
.
And protect it with the usual if __name__ == "__main__":
idiom, so
unit tests
can safely import
this module without unwanted side effects.
The program design doesn't need mutation - so don't allow it; make all of your data structures immutable.
The concept of a "student" with a name doesn't really apply to this OOP design, only a program attempt, course attempt, and course definition. To that end, you should separate your classes a little.
WEIGHT
should be a global or at least a class static.
The standard library is your friend - use statistics
for both a mean and a linear regressor as JH suggests.
Your logic for an empty grade sequence is troubled. If a student has a good record of previous courses, and then enters a new course for which they haven't submitted any work yet, should that really (severely) count against their GPA? Probably not. Instead of assuming 0 for all empty-sequence course grades, you should probably just remove its effect from the GPA. I do not demonstrate this.
Move your demo code to a function.
Use PEP484 type hinting and the new type
keyword.
Suggested
import statistics
from typing import NamedTuple, Literal
type GradeStr = Literal[
'A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F',
]
WEIGHT: dict[GradeStr, float] = {
'A+': 4.3, 'A': 4.0, 'A-': 3.7, 'B+': 3.3, 'B': 3.0, 'B-': 2.7,
'C+': 2.3, 'C': 2.0, 'C-': 1.7, 'D': 1.0, 'F': 0.0,
}
class Course(NamedTuple):
name: str
credits: int
def __str__(self) -> str:
return self.name
class CourseAttempt(NamedTuple):
course: Course
grades: tuple[GradeStr, ...]
@property
def grade_values(self) -> tuple[float, ...]:
return tuple(
WEIGHT[grade] for grade in self.grades
)
@property
def mean(self) -> float:
if len(self.grades) == 0:
return 0
return statistics.fmean(self.grade_values)
@property
def next_predicted_grade(self) -> float:
n = len(self.grades)
match n:
case 0:
return 0
case 1:
return self.grade_values[0]
case _:
slope, intercept = statistics.linear_regression(
range(n), # x
self.grade_values, # y
)
return slope*n + intercept
def __str__(self) -> str:
return f'{self.course}: {self.mean:.2f}'
class ProgramAttempt(NamedTuple):
courses: tuple[CourseAttempt, ...]
@property
def gpa(self) -> float:
if len(self.courses) == 0:
return 0
return statistics.fmean(
data=[attempt.mean for attempt in self.courses],
weights=[attempt.course.credits for attempt in self.courses],
)
def demo() -> None:
year = ProgramAttempt(
courses=(
CourseAttempt(
course=Course(name='Calculus', credits=3),
grades=('B', 'A-'),
),
CourseAttempt(
course=Course(name='Physics', credits=4),
grades=('B+',),
),
),
)
print(f'GPA: {year.gpa:.2f}')
print('Predicted grades per course:')
for course in year.courses:
print(f' {course} -> {course.next_predicted_grade:.2f}')
if __name__ == '__main__':
demo()
GPA: 3.32
Predicted grades per course:
Calculus: 3.35 -> 4.40
Physics: 3.30 -> 3.30