"""CSC148 Assignment 1 === CSC148 Winter 2023 === Department of Computer Science, University of Toronto This code is provided solely for the personal and private use of students taking the CSC148 course at the University of Toronto. Copying for purposes other than this use is expressly prohibited. All forms of distribution of this code, whether as given or with any changes, are expressly prohibited. Authors: Misha Schwartz, Mario Badr, Christine Murad, Diane Horton, Sophia Huynh, Jaisie Sin, Tom Ginsberg, Jonathan Calver, and Jacqueline Smith All of the files in this directory and all subdirectories are: Copyright (c) 2023 Misha Schwartz, Mario Badr, Diane Horton, Sophia Huynh, Jonathan Calver, and Jacqueline Smith === Module Description === This file contains a class that describes a survey as well as classes that describe different types of questions that can be asked on a survey. """ from __future__ import annotations from typing import TYPE_CHECKING, Union from criterion import InvalidAnswerError, HomogeneousCriterion if TYPE_CHECKING: from criterion import Criterion from grouper import Grouping from course import Student class Question: """An abstract class representing a question used in a survey === Public Attributes === id: the id of this question text: the text of this question === Representation Invariants === text is not the empty string """ id: int text: str def __init__(self, id_: int, text: str) -> None: """Initialize this question with the text .""" self.id = id_ self.text = text def __str__(self) -> str: """Return a string representation of this question that contains both the text of this question and a description of all possible answers to this question. You can choose the precise format of this string. """ return f"{self.text}" def validate_answer(self, answer: Answer) -> bool: """Return True iff is a valid answer to this question. """ raise NotImplementedError def get_similarity(self, answer1: Answer, answer2: Answer) -> float: """Return a float between 0.0 and 1.0 indicating how similar two answers are. Preconditions: - and are both valid answers to this question """ if answer1.content == answer2.content: return 1.0 else: return 0.0 class MultipleChoiceQuestion(Question): """A question whose answers can be one of several options === Public Attributes === id: the id of this question text: the text of this question === Private Attributes === _options: the options that the student is allowed to choose from in this question === Representation Invariants === text is not the empty string """ id: int text: str _options: list[str] def __init__(self, id_: int, text: str, options: list[str]) -> None: """Initialize a question with the text and id and possible answers given in . Preconditions: - No two elements in are the same string - contains at least two elements """ Question.__init__(self, id_, text) self._options = options def __str__(self) -> str: """Return a string representation of this question including the text of the question and a description of the possible answers. You can choose the precise format of this string. """ result = f'{self.text}' for option in self._options: result += '\n' + f'- {option}' return result def validate_answer(self, answer: Answer) -> bool: """Return True iff is a valid answer to this question. An answer is valid if its content is one of the answer options for this question. """ return answer.content in self._options class NumericQuestion(Question): """A question whose answer can be an integer between some minimum and maximum value (inclusive). === Public Attributes === id: the id of this question text: the text of this question === Private Attributes === _min_: minimum integer that an answer could be _max_: maximum integer that an answer could be === Representation Invariants === text is not the empty string """ id: int text: str _min_: int _max_: int def __init__(self, id_: int, text: str, min_: int, max_: int) -> None: """Initialize a question with id and text whose possible answers can be any integer between and (inclusive) Preconditions: - min_ < max_ """ Question.__init__(self, id_, text) self._min_ = min_ self._max_ = max_ def validate_answer(self, answer: Answer) -> bool: """Return True iff the content of is an integer between the minimum and maximum (inclusive) possible answers to this question. """ return (answer is not None) and \ (self._min_ <= answer.content <= self._max_) def get_similarity(self, answer1: Answer, answer2: Answer) -> float: """Return the similarity between and over the range of possible answers to this question. Similarity is calculated as follows: 1. first find the absolute difference between .content and .content. 2. divide the value from step 1 by the difference between the maximum and minimum possible answers. 3. subtract the value of step 2 from 1.0 For example: - Maximum similarity is 1.0 and occurs when == - Minimum similarity is 0.0 and occurs when is the minimum possible answer and is the maximum possible answer (or vice versa). Preconditions: - and are both valid answers to this question """ diff = abs(answer1.content - answer2.content) rang = self._max_ - self._min_ return 1.0 - diff / rang class YesNoQuestion(Question): """A question whose answer is either yes (represented by True) or no (represented by False). === Public Attributes === id: the id of this question text: the text of this question === Private Attributes === === Representation Invariants === text is not the empty string """ id: int text: str def __str__(self) -> str: """Return a string representation of this question including the text of the question and a description of the possible answers. You can choose the precise format of this string. """ result = Question.__str__(self) new_result = result + '\n' + '- Yes' + '\n' + '- No' return new_result def validate_answer(self, answer: Answer) -> bool: """Return True iff is a valid answer to this question. An answer is valid if its content is one of the answer options for this question. """ return (answer.content is True) or (answer.content is False) class CheckboxQuestion(MultipleChoiceQuestion): """A question whose answers can be one or more of several options === Public Attributes === id: the id of this question text: the text of this question === Private Attributes === === Representation Invariants === text is not the empty string """ id: int text: str def _helper_check_in_options(self, answer: Answer) -> bool: """Return True iff every item in .content is one of the answer options for the question.""" for ans in answer.content: if ans not in self._options: return False return True def validate_answer(self, answer: Answer) -> bool: """Return True iff is a valid answer to this question. An answer is valid iff: * It is a non-empty list. * It has no duplicate entries. * Every item in it is one of the answer options for this question. """ non_empty = answer.content != [] no_duplicates = True for i in range(len(answer.content)): for j in range(i + 1, len(answer.content)): if answer.content[i] == answer.content[j]: no_duplicates = not no_duplicates return non_empty and \ no_duplicates and \ self._helper_check_in_options(answer) def get_similarity(self, answer1: Answer, answer2: Answer) -> float: """Return the similarity between and . Similarity is defined as the ratio between the number of strings that are common to both .content and .content over the total number of unique strings that appear in both .content and .content. If there are zero unique strings in common, return 1.0. For example, if .content == ['a', 'b', 'c'] and .content == ['c', 'b', 'd'], there are 2 strings common to both: 'c' and 'b'; and there are 4 unique strings that appear in both: 'a', 'b', 'c', and 'd'. Therefore, the similarity between these two answers is 2/4 = 0.5. Preconditions: - and are both valid answers to this question """ count = 0 unique = set() for answer in answer1.content: unique.add(answer) if answer in answer2.content: count += 1 for answer in answer2.content: unique.add(answer) similarity = count / len(unique) return similarity class Answer: """An answer to a question used in a survey === Public Attributes === content: an answer to a single question """ content: Union[str, bool, int, list[str]] def __init__(self, content: Union[str, bool, int, list[str]]) -> None: """Initialize this answer with content """ self.content = content def is_valid(self, question: Question) -> bool: """Return True iff this answer is a valid answer to """ return question.validate_answer(self) class Survey: """A survey containing questions as well as criteria and weights used to evaluate the quality of a group based on their answers to the survey questions. === Private Attributes === _questions: a dictionary mapping a question's id to the question itself _criteria: a dictionary mapping a question's id to its associated criterion _weights: a dictionary mapping a question's id to a weight -- an integer representing the importance of this criteria. === Representation Invariants === No two questions on this survey have the same id Each key in _questions equals the id attribute of its value The dictionaries _questions, _criteria, and _weights all have the same keys Each value in _weights is greater than 0 NOTE: The weights associated with the questions in a survey do NOT have to sum up to any particular amount. """ _questions: dict[int, Question] _criteria: dict[int, Criterion] _weights: dict[int, int] def __init__(self, questions: list[Question]) -> None: """Initialize a new survey that contains every question in . This new survey should use a HomogeneousCriterion as a default criterion and should use 1 as a default weight. """ self._questions = {} self._criteria = {} self._weights = {} for question in questions: if question.id not in self._questions: self._questions[question.id] = question self._criteria[question.id] = HomogeneousCriterion() self._weights[question.id] = 1 def __len__(self) -> int: """Return the number of questions in this survey """ return len(self._questions) def __contains__(self, question: Question) -> bool: """Return True iff there is a question in this survey with the same id as . """ for qn in self._questions: if qn == question.id: return True return False def __str__(self) -> str: """Return a string containing the string representation of all questions in this survey. You can choose the precise format of this string. """ i = 1 result = 'Survey' for question in self._questions: result += '\n' + f'{i}. ' + str(self._questions[question]) i += 1 return result def get_questions(self) -> list[Question]: """Return a list of all questions in this survey """ qn_list = [] for qn in self._questions: qn_list.append(self._questions[qn]) return qn_list def _get_criterion(self, question: Question) -> Criterion: """Return the criterion associated with in this survey. Preconditions: - .id occurs in this survey """ return self._criteria[question.id] def _get_weight(self, question: Question) -> int: """Return the weight associated with in this survey. Preconditions: - .id occurs in this survey """ return self._weights[question.id] def set_weight(self, weight: int, question: Question) -> bool: """Set the weight associated with to and return True. If .id does not occur in this survey, do not set the and return False instead. """ if question.id not in self._questions: return False else: self._weights[question.id] = weight return True def set_criterion(self, criterion: Criterion, question: Question) -> bool: """Set the criterion associated with to and return True. If .id does not occur in this survey, do not set the and return False instead. """ if question.id not in self._questions: return False else: self._criteria[question.id] = criterion return True def score_students(self, students: list[Student]) -> float: """Return a quality score for calculated based on their answers to the questions in this survey, and the associated criterion and weight for each question. The score is determined using the following algorithm: 1. For each question in this survey, find the question's associated criterion (do we want homogeneous answers, for instance), weight, and answers to the question. Use the score_answers method for its criterion to calculate how well the answers satisfy the criterion. Multiply this quality score by the question's weight. 2. Find the average of all quality scores from step 1. This method should NOT throw an InvalidAnswerError. If one occurs during the execution of this method or if there are no questions in , return zero. Preconditions: - All students in have an answer to all questions in this survey - len(students) > 0 """ scores = [] if len(self._questions) == 0: return 0 for qn in self._questions: criteria = self._criteria[qn] weight = self._weights[qn] answers = [] for student in students: answers.append(student.get_answer(self._questions[qn])) try: score = criteria.score_answers(self._questions[qn], answers) \ * weight except InvalidAnswerError: return 0 scores.append(score) quality_score = sum(scores) / len(scores) return quality_score def score_grouping(self, grouping: Grouping) -> float: """Return a score for calculated based on the answers of each student in each group in to the questions in . If there are no groups in return 0.0. Otherwise, the score is determined using the following algorithm: 1. For each group in , calculate the score for the members of this based on their answers to the questions in this survey. 2. Return the average of all the scores calculated in step 1. Preconditions: - All students in the groups in have an answer to all questions in this survey """ scores = [] if len(grouping.get_groups()) == 0: return 0.0 for group in grouping.get_groups(): score = self.score_students(group.get_members()) scores.append(score) return sum(scores) / len(scores) if __name__ == '__main__': import python_ta python_ta.check_all(config={'extra-imports': ['typing', 'criterion', 'course', 'grouper'], 'disable': ['E9992']})