diff --git a/aprendizations/__init__.py b/aprendizations/__init__.py index a84bdd0..467d912 100644 --- a/aprendizations/__init__.py +++ b/aprendizations/__init__.py @@ -30,7 +30,7 @@ are progressively uncovered as the students progress. ''' APP_NAME = 'aprendizations' -APP_VERSION = '2019.05.dev3' +APP_VERSION = '2019.07.dev1' APP_DESCRIPTION = __doc__ __author__ = 'Miguel BarĂ£o' diff --git a/aprendizations/knowledge.py b/aprendizations/knowledge.py deleted file mode 100644 index ec16552..0000000 --- a/aprendizations/knowledge.py +++ /dev/null @@ -1,236 +0,0 @@ - -# python standard library -import random -from datetime import datetime -import logging - -# third party libraries -import networkx as nx - -# setup logger for this module -logger = logging.getLogger(__name__) - - -# ---------------------------------------------------------------------------- -# kowledge state of each student....?? -# Contains: -# state - dict of topics with state of unlocked topics -# deps - access to dependency graph shared between students -# topic_sequence - list with the order of recommended topics -# ---------------------------------------------------------------------------- -class StudentKnowledge(object): - # ======================================================================= - # methods that update state - # ======================================================================= - def __init__(self, deps, factory, state={}): - self.deps = deps # shared dependency graph - self.factory = factory # question factory - self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...} - - self.update_topic_levels() # applies forgetting factor - self.unlock_topics() # whose dependencies have been completed - self.topic_sequence = self.recommend_topic_sequence() # ['ref1', ...] - self.current_topic = None - - # ------------------------------------------------------------------------ - # Updates the proficiency levels of the topics, with forgetting factor - # FIXME no dependencies are considered yet... - # ------------------------------------------------------------------------ - def update_topic_levels(self): - now = datetime.now() - for tref, s in self.state.items(): - dt = now - s['date'] - s['level'] *= 0.98 ** dt.days # forgetting factor - - # ------------------------------------------------------------------------ - # Unlock topics whose dependencies are satisfied (> min_level) - # ------------------------------------------------------------------------ - def unlock_topics(self): - for topic in self.deps.nodes(): - if topic not in self.state: # if locked - pred = self.deps.predecessors(topic) - min_level = self.deps.node[topic]['min_level'] - if all(d in self.state and self.state[d]['level'] > min_level - for d in pred): # all deps are greater than min_level - - self.state[topic] = { - 'level': 0.0, # unlocked - 'date': datetime.now() - } - logger.debug(f'[unlock_topics] Unlocked "{topic}".') - # else: # lock this topic if deps do not satisfy min_level - # del self.state[topic] - - # ------------------------------------------------------------------------ - # Start a new topic. - # questions: list of generated questions to do in the topic - # current_question: the current question to be presented - # ------------------------------------------------------------------------ - async def start_topic(self, topic): - logger.debug(f'[start_topic] topic "{topic}"') - - if self.current_topic == topic: - logger.info('Restarting current topic is not allowed.') - return - - # do not allow locked topics - if self.is_locked(topic): - logger.debug(f'[start_topic] topic "{topic}" is locked') - return - - # starting new topic - self.current_topic = topic - self.correct_answers = 0 - self.wrong_answers = 0 - - t = self.deps.node[topic] - k = t['choose'] - if t['shuffle_questions']: - questions = random.sample(t['questions'], k=k) - else: - questions = t['questions'][:k] - logger.debug(f'[start_topic] questions: {", ".join(questions)}') - - # synchronous - # self.questions = [self.factory[ref].generate() - # for ref in questions] - - # asynchronous: - self.questions = [await self.factory[ref].generate_async() - for ref in questions] - - n = len(self.questions) - logger.debug(f'[start_topic] generated {n} questions') - - # get first question - self.next_question() - - # ------------------------------------------------------------------------ - # The topic has finished and there are no more questions. - # The topic level is updated in state and unlocks are performed. - # The current topic is unchanged. - # ------------------------------------------------------------------------ - def finish_topic(self): - logger.debug(f'[finish_topic] current_topic {self.current_topic}') - - self.state[self.current_topic] = { - 'date': datetime.now(), - 'level': self.correct_answers / (self.correct_answers + - self.wrong_answers) - } - # self.current_topic = None - self.unlock_topics() - - # ------------------------------------------------------------------------ - # corrects current question with provided answer. - # implements the logic: - # - if answer ok, goes to next question - # - if wrong, counts number of tries. If exceeded, moves on. - # ------------------------------------------------------------------------ - async def check_answer(self, answer): - logger.debug('[check_answer]') - - q = self.current_question - q['answer'] = answer - q['finish_time'] = datetime.now() - await q.correct_async() - logger.debug(f'[check_answer] Grade {q["grade"]:.2} in {q["ref"]}') - - if q['grade'] > 0.999: - self.correct_answers += 1 - self.next_question() - action = 'right' - - else: - self.wrong_answers += 1 - self.current_question['tries'] -= 1 - - if self.current_question['tries'] > 0: - action = 'try_again' - else: - action = 'wrong' - if self.current_question['append_wrong']: - logger.debug('[check_answer] Wrong, append new instance') - self.questions.append(self.factory[q['ref']].generate()) - self.next_question() - - # returns corrected question (not new one) which might include comments - return q, action - - # ------------------------------------------------------------------------ - # Move to next question - # ------------------------------------------------------------------------ - def next_question(self): - try: - self.current_question = self.questions.pop(0) - except IndexError: - self.current_question = None - self.finish_topic() - else: - self.current_question['start_time'] = datetime.now() - default_maxtries = self.deps.nodes[self.current_topic]['max_tries'] - maxtries = self.current_question.get('max_tries', default_maxtries) - self.current_question['tries'] = maxtries - logger.debug(f'[next_question] "{self.current_question["ref"]}"') - - return self.current_question # question or None - - # ======================================================================== - # pure functions of the state (no side effects) - # ======================================================================== - - def topic_has_finished(self): - return self.current_question is None - - # ------------------------------------------------------------------------ - # compute recommended sequence of topics ['a', 'b', ...] - # ------------------------------------------------------------------------ - def recommend_topic_sequence(self, target=None): - tt = list(nx.topological_sort(self.deps)) - unlocked = [t for t in tt if t in self.state] - locked = [t for t in tt if t not in unlocked] - return unlocked + locked - - # ------------------------------------------------------------------------ - def get_current_question(self): - return self.current_question - - # ------------------------------------------------------------------------ - def get_current_topic(self): - return self.current_topic - - # ------------------------------------------------------------------------ - def is_locked(self, topic): - return topic not in self.state - - # ------------------------------------------------------------------------ - # Return list of {ref: 'xpto', name: 'long name', leve: 0.5} - # Levels are in the interval [0, 1] if unlocked or None if locked. - # Topics unlocked but not yet done have level 0.0. - # ------------------------------------------------------------------------ - def get_knowledge_state(self): - return [{ - 'ref': ref, - 'type': self.deps.nodes[ref]['type'], - 'name': self.deps.nodes[ref]['name'], - 'level': self.state[ref]['level'] if ref in self.state else None - } for ref in self.topic_sequence] - - # ------------------------------------------------------------------------ - def get_topic_progress(self): - return self.correct_answers / (1 + self.correct_answers + - len(self.questions)) - - # ------------------------------------------------------------------------ - def get_topic_level(self, topic): - return self.state[topic]['level'] - - # ------------------------------------------------------------------------ - def get_topic_date(self, topic): - return self.state[topic]['date'] - - # ------------------------------------------------------------------------ - # Recommends a topic to practice/learn from the state. - # ------------------------------------------------------------------------ - # def get_recommended_topic(self): # FIXME untested - # return min(self.state.items(), key=lambda x: x[1]['level'])[0] diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index 6295640..9991be3 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -15,7 +15,7 @@ import networkx as nx # this project from .models import Student, Answer, Topic, StudentTopic -from .knowledge import StudentKnowledge +from .student import StudentState from .questions import QFactory from .tools import load_yaml @@ -155,8 +155,8 @@ class LearnApp(object): self.online[uid] = { 'number': uid, 'name': name, - 'state': StudentKnowledge(deps=self.deps, factory=self.factory, - state=state), + 'state': StudentState(deps=self.deps, factory=self.factory, + state=state), 'counter': counter + 1, # counts simultaneous logins } @@ -441,16 +441,16 @@ class LearnApp(object): total_topics = s.query(Topic).count() # answer performance - totalans = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)). - group_by(Answer.student_id). - all()) - rightans = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)). - filter(Answer.grade == 1.0). - group_by(Answer.student_id). - all()) + total = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)). + group_by(Answer.student_id). + all()) + right = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)). + filter(Answer.grade == 1.0). + group_by(Answer.student_id). + all()) # compute percentage of right answers - perf = {uid: rightans.get(uid, 0.0)/totalans[uid] for uid in totalans} + perf = {uid: right.get(uid, 0.0)/total[uid] for uid in total} # compute topic progress prog = {s[0]: 0.0 for s in students} diff --git a/aprendizations/student.py b/aprendizations/student.py new file mode 100644 index 0000000..3989467 --- /dev/null +++ b/aprendizations/student.py @@ -0,0 +1,236 @@ + +# python standard library +import random +from datetime import datetime +import logging + +# third party libraries +import networkx as nx + +# setup logger for this module +logger = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------------- +# kowledge state of each student....?? +# Contains: +# state - dict of topics with state of unlocked topics +# deps - access to dependency graph shared between students +# topic_sequence - list with the order of recommended topics +# ---------------------------------------------------------------------------- +class StudentState(object): + # ======================================================================= + # methods that update state + # ======================================================================= + def __init__(self, deps, factory, state={}): + self.deps = deps # shared dependency graph + self.factory = factory # question factory + self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...} + + self.update_topic_levels() # applies forgetting factor + self.unlock_topics() # whose dependencies have been completed + self.topic_sequence = self.recommend_topic_sequence() # ['ref1', ...] + self.current_topic = None + + # ------------------------------------------------------------------------ + # Updates the proficiency levels of the topics, with forgetting factor + # FIXME no dependencies are considered yet... + # ------------------------------------------------------------------------ + def update_topic_levels(self): + now = datetime.now() + for tref, s in self.state.items(): + dt = now - s['date'] + s['level'] *= 0.98 ** dt.days # forgetting factor + + # ------------------------------------------------------------------------ + # Unlock topics whose dependencies are satisfied (> min_level) + # ------------------------------------------------------------------------ + def unlock_topics(self): + for topic in self.deps.nodes(): + if topic not in self.state: # if locked + pred = self.deps.predecessors(topic) + min_level = self.deps.node[topic]['min_level'] + if all(d in self.state and self.state[d]['level'] > min_level + for d in pred): # all deps are greater than min_level + + self.state[topic] = { + 'level': 0.0, # unlocked + 'date': datetime.now() + } + logger.debug(f'[unlock_topics] Unlocked "{topic}".') + # else: # lock this topic if deps do not satisfy min_level + # del self.state[topic] + + # ------------------------------------------------------------------------ + # Start a new topic. + # questions: list of generated questions to do in the topic + # current_question: the current question to be presented + # ------------------------------------------------------------------------ + async def start_topic(self, topic): + logger.debug(f'[start_topic] topic "{topic}"') + + if self.current_topic == topic: + logger.info('Restarting current topic is not allowed.') + return + + # do not allow locked topics + if self.is_locked(topic): + logger.debug(f'[start_topic] topic "{topic}" is locked') + return + + # starting new topic + self.current_topic = topic + self.correct_answers = 0 + self.wrong_answers = 0 + + t = self.deps.node[topic] + k = t['choose'] + if t['shuffle_questions']: + questions = random.sample(t['questions'], k=k) + else: + questions = t['questions'][:k] + logger.debug(f'[start_topic] questions: {", ".join(questions)}') + + # synchronous + # self.questions = [self.factory[ref].generate() + # for ref in questions] + + # asynchronous: + self.questions = [await self.factory[ref].generate_async() + for ref in questions] + + n = len(self.questions) + logger.debug(f'[start_topic] generated {n} questions') + + # get first question + self.next_question() + + # ------------------------------------------------------------------------ + # The topic has finished and there are no more questions. + # The topic level is updated in state and unlocks are performed. + # The current topic is unchanged. + # ------------------------------------------------------------------------ + def finish_topic(self): + logger.debug(f'[finish_topic] current_topic {self.current_topic}') + + self.state[self.current_topic] = { + 'date': datetime.now(), + 'level': self.correct_answers / (self.correct_answers + + self.wrong_answers) + } + # self.current_topic = None + self.unlock_topics() + + # ------------------------------------------------------------------------ + # corrects current question with provided answer. + # implements the logic: + # - if answer ok, goes to next question + # - if wrong, counts number of tries. If exceeded, moves on. + # ------------------------------------------------------------------------ + async def check_answer(self, answer): + logger.debug('[check_answer]') + + q = self.current_question + q['answer'] = answer + q['finish_time'] = datetime.now() + await q.correct_async() + logger.debug(f'[check_answer] Grade {q["grade"]:.2} in {q["ref"]}') + + if q['grade'] > 0.999: + self.correct_answers += 1 + self.next_question() + action = 'right' + + else: + self.wrong_answers += 1 + self.current_question['tries'] -= 1 + + if self.current_question['tries'] > 0: + action = 'try_again' + else: + action = 'wrong' + if self.current_question['append_wrong']: + logger.debug('[check_answer] Wrong, append new instance') + self.questions.append(self.factory[q['ref']].generate()) + self.next_question() + + # returns corrected question (not new one) which might include comments + return q, action + + # ------------------------------------------------------------------------ + # Move to next question + # ------------------------------------------------------------------------ + def next_question(self): + try: + self.current_question = self.questions.pop(0) + except IndexError: + self.current_question = None + self.finish_topic() + else: + self.current_question['start_time'] = datetime.now() + default_maxtries = self.deps.nodes[self.current_topic]['max_tries'] + maxtries = self.current_question.get('max_tries', default_maxtries) + self.current_question['tries'] = maxtries + logger.debug(f'[next_question] "{self.current_question["ref"]}"') + + return self.current_question # question or None + + # ======================================================================== + # pure functions of the state (no side effects) + # ======================================================================== + + def topic_has_finished(self): + return self.current_question is None + + # ------------------------------------------------------------------------ + # compute recommended sequence of topics ['a', 'b', ...] + # ------------------------------------------------------------------------ + def recommend_topic_sequence(self, target=None): + tt = list(nx.topological_sort(self.deps)) + unlocked = [t for t in tt if t in self.state] + locked = [t for t in tt if t not in unlocked] + return unlocked + locked + + # ------------------------------------------------------------------------ + def get_current_question(self): + return self.current_question + + # ------------------------------------------------------------------------ + def get_current_topic(self): + return self.current_topic + + # ------------------------------------------------------------------------ + def is_locked(self, topic): + return topic not in self.state + + # ------------------------------------------------------------------------ + # Return list of {ref: 'xpto', name: 'long name', leve: 0.5} + # Levels are in the interval [0, 1] if unlocked or None if locked. + # Topics unlocked but not yet done have level 0.0. + # ------------------------------------------------------------------------ + def get_knowledge_state(self): + return [{ + 'ref': ref, + 'type': self.deps.nodes[ref]['type'], + 'name': self.deps.nodes[ref]['name'], + 'level': self.state[ref]['level'] if ref in self.state else None + } for ref in self.topic_sequence] + + # ------------------------------------------------------------------------ + def get_topic_progress(self): + return self.correct_answers / (1 + self.correct_answers + + len(self.questions)) + + # ------------------------------------------------------------------------ + def get_topic_level(self, topic): + return self.state[topic]['level'] + + # ------------------------------------------------------------------------ + def get_topic_date(self, topic): + return self.state[topic]['date'] + + # ------------------------------------------------------------------------ + # Recommends a topic to practice/learn from the state. + # ------------------------------------------------------------------------ + # def get_recommended_topic(self): # FIXME untested + # return min(self.state.items(), key=lambda x: x[1]['level'])[0] -- libgit2 0.21.2