const _ = require("lodash");
const { lessons, events } = require("./data");
const {
getNumberOfOccurences,
getAverageAndMean,
filterInvalidQuestions,
sortAlphabetically,
sortNumerically,
sortByValueAsc,
sortByValueDesc,
sortByWeekday,
} = require("./src/helpers");
const { weekdays, eventTypes, blocks } = require("./src/constants");
const {
makeChart,
makeBarChart,
makeMultiBarChart,
makeFrequencyDiagram,
makeAvgAndMeanDiagram,
} = require("./src/charts");
Die Daten wurden mit Hilfe von Prakti aufgezeichnet. Der Export besteht aus einer Liste von Events mit dazugehörigem Timestamp (einer absoluten Zeitangabe in Millisekunden):
> NEW_SUBJECT - 1567503251097
> NEW_QUESTION_ASKED - 1567505950365
> FIRST_STUDENT_RAISED_HAND - 1567505951876
> FIRST_STUDENT_ANSWERED_QUESTION - 1567505960413
> NEW_QUESTION_ASKED - 1567505995484
> ...
In data/index.js
wird diese Liste nun in logische Einheiten geordnet: in Unterrichtsstunden und Fragen. Da Prakti seinen Zustand als endlichen deterministischen Automaten) modelliert und wir die Zustandsübergänge (Events, dargestellt an den Pfeilen) protokolliert haben, ist es möglich, bestimmten Mustern von Events verschiedene Bedeutungen zuzuordnen.
So wissen wir, dass immer wenn ein NEW_SUBJECT
-Event protokolliert wurde, alle nachfolgenden Events bis zum nächsten NEW_SUBJECT
-Event zu diesem Fach und zu dieser Lehrkraft gehören.
Gleichzeitig wissen wir, dass eine neue Frage immer mit einem Event beginnt, das in den Zustand newQuestionAsked
führt. Also werten wir sowohl das Stellen einer neuen Frage (NEW_QUESTION_ASKED
) als auch das Vereinfachen (TEACHER_SIMPLIFIED_QUESTION
) und das Stellen einer Alternativfrage (TEACHER_POSED_NEW_QUESTION
) als eine neue Frage.
Es ist auch möglich, dass eine Frage nicht durch eine SchülerIn beantwortet wird (bspw. wenn die Lehrkraft die Frage selbst beantwortet (TEACHER_ANSWERED_QUESTION
). Somit ist es möglich zwischen beantworteten und nicht beantworteten (abgebrochenen) Fragen zu unterscheiden.
Insgesamt haben wir nun also Liste an Unterrichtsstunden welche wiederum eine Liste der darin gestellten Fragen enthält:
> Stunde
> Frage
> Frage
> Frage
> Stunde
> Frage
> Frage
> ...
> ...
Innerhalb der Datenverarbeitung wurden bereits bestimmte Werte aggregiert. Beispielsweise wurde für jede Frage bestimmt,
wasAnswered
) (und falls nicht warum sie abgebrochen wurde (reasonCanceled
))timeUntilFinished
)timeUntilTeacherMotivatesForFirstTime
)timeUntilFirstStudentRaisesHand
)timeBetweenFirstStudentRaisesHandAndQuestionFinished
)firstStudentHasAnswered
)Für jede Stunde wurde bestimmt,
subjectArea
) (Wir verarbeiten also nicht Fächer, sondern Fachbereiche. Damit erhoffe ich mir, einfacher Korrelationen herstellen zu können)teacherPseudonym
) (Für die Datenanalyse habe ich Klarnamen mit Pseudonymen ersetzt)block
), zu welcher Zeit (time.hour
und time.minute
), in welcher Woche (time.weekNumber
) und an welchem Wochentag (weekDay
) die Stunde gegeben wurde Der Datensatz hat also grob folgende Struktur:
[{
subjectArea,
teacherPseudonym,
time: { minute, hour, weekDay }
block,
weekDay,
questions: [{
wasAnswered,
timeUntilFinished,
timeUntilFirstStudentRaisesHand,
firstStudentHasAnswered,
reasonCanceled,
timeUntilTeacherMotivatesForFirstTime,
timeBetweenFirstStudentRaisesHandAndQuestionFinished,
events: [{
id,
action,
time,
diff,
totalDiff
}, ...]
}, ...]
}, ...]
Auf dieser Grundlage sind wir nun in der Lage, Abfragen auszuführen. Alle Zeiten sind in Sekunden, soweit nicht anders angegeben.
const questions = lessons
.map(l => l.questions)
.flat()
.filter(filterInvalidQuestions); // one question has inconsistent data and is removed here
const questionCount = questions.length;
const lessonCount = lessons.length;
const avgQuestionCountPerLesson = _(questionCount / lessonCount).round(2);
({ lessonCount, questionCount, avgQuestionCountPerLesson });
Als abgebrochene Frage zählen alle Fragen die nicht von einer SchülerIn beantwortet wurden. Das passiert, wenn die Lehrkraft reagiert (TEACHER_POSED_NEW_QUESTION
, TEACHER_SIMPLIFIED_QUESTION
, TEACHER_CANCELED_QUESTION
, TEACHER_ANSWERED_QUESTION
, TEACHER_REACTED_UNEXPECTEDLY
)
const answeredQuestions = questions.filter(q => q.wasAnswered);
const answeredQuestionCount = answeredQuestions.length;
const percentageOfAnsweredQuestions = answeredQuestionCount / questionCount;
({
answeredQuestionCount,
canceledQuestionCount: questionCount - answeredQuestionCount,
percentageOfAnsweredQuestions,
});
const questionTimes = answeredQuestions.map(question => question.timeUntilFinished);
const { avg: avgQuestionTime, mean: meanQuestionTime } = getAverageAndMean(
questionTimes,
);
({ avgQuestionTime, meanQuestionTime });
Die Verteilung der Fragezeiten lässt sich auch gut in einem Diagramm darstellen:
const roundedQuestionTimes = questionTimes.map(q => Math.round(q / 1000));
makeBarChart(getNumberOfOccurences(roundedQuestionTimes), {
title: "Anzahl beobachteter Fragen pro Wartezeit",
x: "Zeit in Sekunden",
y: "Anzahl Fragen",
});
Um die Häufigkeiten noch etwas besser abschätzen, fassen wir mehrere Zeiten zu Kategorien zusammen und stellen das Ergebnis graphisch dar. Dargestellt wird immer das Interval untereGrenze ≤ x < obereGrenze
, für ≤2
also 0 ≤ x < 2
.
const bucketedValues = roundedQuestionTimes.map(time => {
if (time < 2) return "≤2";
if (time < 4) return "≤4";
if (time < 6) return "≤6";
if (time < 8) return "≤8";
if (time < 10) return "≤10";
if (time < 15) return "≤15";
if (time >= 15) return "≥16";
});
makeBarChart(getNumberOfOccurences(bucketedValues), {
title: "Anzahl beobachteter Fragen pro Wartezeit",
x: "Zeit in Sekunden",
y: "Anzahl Fragen",
});
Hier möchte ich einen Überblick über die Verteilung der Daten bzgl. folgender Paramter bekommen:
makeFrequencyDiagram(
lessons.map(l => l.subjectArea),
{ title: "Anzahl beobachteter Blöcke pro Fachbereich", x: "Fachbereich", y: "Anzahl beobachteter Blöcke" },
sortAlphabetically,
);
const questionsPerSubjectArea = _(lessons)
.groupBy(l => l.subjectArea)
.values()
.map(arr => [arr[0].subjectArea, _(arr).map(arr2 => arr2.questions.length).sum()])
.sort(sortNumerically)
.fromPairs()
.value();
console.log(questionsPerSubjectArea);
makeBarChart(questionsPerSubjectArea, {
title: "Anzahl beobachteter Fragen pro Fachbereich",
x: "Fachbereich",
y: "Anzahl Fragen",
});
makeFrequencyDiagram(
lessons.map(l => l.teacherPseudonym),
{
title: "Anzahl beobachteter Blöcke pro Lehrkraft",
x: "Lehrkraft (Pseudonym)",
y: "Anzahl beobachteter Blöcke",
},
sortAlphabetically,
);
const questionsPerTeacher = _(lessons)
.groupBy(l => l.teacherPseudonym)
.values()
.map(arr => [arr[0].teacherPseudonym, _(arr).map(arr2 => arr2.questions.length).sum()])
.sort(sortNumerically)
.fromPairs()
.value();
console.log(questionsPerTeacher);
makeBarChart(questionsPerTeacher, {
title: "Anzahl beobachteter Fragen pro Lehrkraft",
x: "Lehrkraft (Pseudonym)",
y: "Anzahl Fragen",
});
Die Blockstunden haben folgende Anfangszeiten:
blocks
.filter(b => b.name !== 'INVALID')
.map(b => `${b.name} startet um ${b.hour}:${b.minute.toString().padEnd(2, "0")}`)
.join('\n');
makeFrequencyDiagram(
lessons.map(l => l.block),
{
title: "Anzahl beobachteter Blöcke",
x: "Block",
y: "Anzahl",
},
sortAlphabetically,
);
const questionsPerBlock = _(lessons)
.groupBy(l => l.block)
.values()
.map(arr => [arr[0].block, _(arr).map(arr2 => arr2.questions.length).sum()])
.sort(sortAlphabetically)
.fromPairs()
.value();
console.log(questionsPerBlock);
makeBarChart(questionsPerBlock, {
title: "Anzahl beobachteter Fragen pro Block",
x: "Block",
y: "Anzahl Fragen",
});
makeFrequencyDiagram(
lessons.map(l => l.weekDay),
{
title: "Anzahl beobachteter Blöcke pro Wochentag",
x: "Tag",
y: "Anzahl beobachteter Blöcke",
},
sortByWeekday,
);
const questionsPerWeekday = _(lessons)
.groupBy(l => l.weekDay)
.values()
.map(arr => [arr[0].weekDay, _(arr).map(arr2 => arr2.questions.length).sum()])
.sort(sortByWeekday)
.fromPairs()
.value();
console.log(questionsPerWeekday);
makeBarChart(questionsPerWeekday, {
title: "Anzahl beobachteter Fragen pro Wochentag",
x: "Wochentag",
y: "Anzahl Fragen",
});
makeFrequencyDiagram(
lessons.map(l => l.time.weekNumber),
{
title: "Anzahl beobachteter Blöcke pro KW",
x: "KW",
y: "Anzahl beobachteter Blöcke",
},
sortNumerically,
);
const questionsPerWeek = _(lessons)
.groupBy(l => l.time.weekNumber)
.values()
.map(arr => [arr[0].time.weekNumber, _(arr).map(arr2 => arr2.questions.length).sum()])
.sort(sortNumerically)
.fromPairs()
.value();
console.log(questionsPerWeek);
makeBarChart(questionsPerWeek, {
title: "Anzahl beobachteter Fragen pro KW",
x: "KW",
y: "Anzahl Fragen",
});
// XXX: for all questions
const {
avg: avgTimeUntilFirstStudentRaisedHand,
mean: meanTimeUntilFirstStudentRaisedHand,
} = getAverageAndMean(questions.map(q => q.timeUntilFirstStudentRaisesHand));
({
avgTimeUntilFirstStudentRaisedHand,
meanTimeUntilFirstStudentRaisedHand,
});
// XXX: only for answeredQuestions
const {
avg: avgTimeUntilStudentAnswer,
mean: meanTimeUntilStudentAnswer,
} = getAverageAndMean(answeredQuestions.map(q => q.timeUntilFinished));
({
avgTimeUntilStudentAnswer,
meanTimeUntilStudentAnswer,
});
// XXX: for answeredQuestions
const {
avg: avgTimeBetweenFirstStudentRaisesHandAndQuestionFinished,
mean: meanTimeBetweenFirstStudentRaisesHandAndQuestionFinished,
} = getAverageAndMean(
answeredQuestions.map(
q => q.timeBetweenFirstStudentRaisesHandAndQuestionFinished,
),
);
({
avgTimeBetweenFirstStudentRaisesHandAndQuestionFinished,
meanTimeBetweenFirstStudentRaisesHandAndQuestionFinished,
});
makeFrequencyDiagram(
events.map(e => e.action),
{ title: "Häufigkeit von Events", x: "Event", y: "Anzahl" },
sortByValueDesc,
);
makeFrequencyDiagram(
events.map(e => e.action).filter(action => action.includes("TEACHER")),
{ title: "Häufigkeit von Events", x: "Event", y: "Anzahl" },
sortByValueDesc,
);
Zu TEACHER_STARTED_REACTING
sei gesagt, dass jede Reaktion der Lehrkraft eingeleitet wird mit TEACHER_STARTED_REACTING
. Das bedeutet, dass im ganzen Datensatz immer folgendes Muster zu finden ist (siehe Automaten-Diagramm oben):
> ...
> TEACHER_STARTED_REACTING
> TEACHER_*
> ...
// XXX: for answeredQuestions
const firstStudentAnsweredCount = answeredQuestions
.map(q => q.firstStudentHasAnswered)
.filter(b => b).length;
const nthStudentAnsweredCount = answeredQuestionCount - firstStudentAnsweredCount;
({
answeredQuestionCount,
firstStudentAnsweredCount,
nthStudentAnsweredCount,
firstStudentAnsweredPercent: firstStudentAnsweredCount / answeredQuestionCount,
nthStudentAnsweredPercent: nthStudentAnsweredCount / answeredQuestionCount,
});
makeAvgAndMeanDiagram(lessons, "subjectArea", "Fachbereich", sortAlphabetically);
makeAvgAndMeanDiagram(lessons, "block", "Block", sortAlphabetically);
makeAvgAndMeanDiagram(lessons, "teacherPseudonym", "Lehrkraft", sortAlphabetically);
const timesUntilCanceled = questions
.filter(q => !q.wasAnswered)
.map(q => q.timeUntilFinished);
const {
avg: avgTimeUntilCanceled,
mean: meanTimeUntilCanceled,
} = getAverageAndMean(timesUntilCanceled);
({
avgTimeUntilCanceled,
meanTimeUntilCanceled,
});
// XXX: all questions
const motivationTimes = questions.map(
q => q.timeUntilTeacherMotivatesForFirstTime,
);
const timesMotivated = motivationTimes.filter(t => t !== null).length;
const timesNotMotivated = questionCount - timesMotivated;
const percentageMotivated = timesMotivated / questionCount;
const percentageNotMotivated = timesNotMotivated / questionCount;
const { avg: avgTimeUntilMotivated, mean: meanTimeUntilMotivated } = getAverageAndMean(
motivationTimes.filter(t => t !== null),
);
({
timesMotivated,
timesNotMotivated,
percentageMotivated,
percentageNotMotivated,
avgTimeUntilMotivated,
meanTimeUntilMotivated,
});
const answeredQuestionsWithMotivation = questions.filter(
q => q.timeUntilTeacherMotivatesForFirstTime && q.wasAnswered,
);
const timesMotivatedAndAnswered = answeredQuestionsWithMotivation.length;
const timesMotivatedAndNotAnswered = timesMotivated - timesMotivatedAndAnswered;
const timesMotivatedAndAnsweredPercent =
timesMotivatedAndAnswered / timesMotivated;
({
timesMotivatedAndAnswered,
timesMotivatedAndNotAnswered,
timesMotivatedAndAnsweredPercent,
});