Analýza průběžného kvízu

In [1]:
# Výchozí parametry notebooku pro Papermill
FILENAME          = 'bizma1.json'
START             = '2020-09-24 20:00:00+0100'
DEADLINE          = '2020-10-11 23:59:00+0100'
SCORE_TO_PASS     = 15  # MARAST bodů
CLUSTER_THRESHOLD = 120 # minut
In [2]:
# Parameters
FILENAME = "bizma3.json"
START = "2020-10-19 12:00:00+0100"
DEADLINE = "2020-11-08 23:59:00+0100"
SCORE_TO_PASS = 15
In [3]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.cluster.hierarchy import fclusterdata

0. Načtení a předzpracování

Načtení dat z JSON formátu poskytovaného MARAST API, upravení datových typů sloupců a zpracování počátku/deadline.

In [4]:
df             = pd.read_json(FILENAME)
df.created_at  = pd.to_datetime(df.created_at, utc=True)
df.updated_at  = pd.to_datetime(df.updated_at, utc=True)
df.answered_at = pd.to_datetime(df.answered_at, utc=True)
df.score       = df.score.fillna(0).astype(np.int64)

start    = pd.to_datetime(START, utc=True)
deadline = pd.to_datetime(DEADLINE, utc=True)

Dopočtení dalších základních užitečných údajů:

  • doba od vytvoření otázky do zodpovězení,
  • doba zbývající do deadline.

Zahození dat, která pravděpodobně vznikla experimentováním nebo řešením singularit.

In [5]:
for idx, row in df.iterrows():
    # doba od vytvoření otázky do zodpovězení (v minutách)
    if row['answered_at']:
        df.at[idx, 'duration'] = (row['answered_at'] - row['created_at']).seconds / 60
        # doba od zodpovězení otázky do deadline (v minutách)
        df.at[idx, 'to_deadline_answer'] = (deadline - row['answered_at']).total_seconds() / 60

    # doba od vytvoření otázky do deadline (v minutách)
    df.at[idx, 'to_deadline'] = (deadline - row['created_at']).total_seconds() / 60

df = df[(df.created_at >= start) & (df.created_at <= deadline)]

Souhrn sloupců, pro kontrolu.

In [6]:
df.columns
Out[6]:
Index(['problem_id', 'anonymous_user_id', 'is_open', 'is_correctly_answered',
       'score', 'state', 'kind', 'solution', 'created_at', 'updated_at',
       'answered_at', 'is_modified', 'duration', 'to_deadline_answer',
       'to_deadline'],
      dtype='object')

Přehled datových typů sloupců.

In [7]:
df.dtypes
Out[7]:
problem_id                             int64
anonymous_user_id                     object
is_open                                 bool
is_correctly_answered                   bool
score                                  int64
state                                 object
kind                                  object
solution                              object
created_at               datetime64[ns, UTC]
updated_at               datetime64[ns, UTC]
answered_at              datetime64[ns, UTC]
is_modified                             bool
duration                             float64
to_deadline_answer                   float64
to_deadline                          float64
dtype: object

Extrakce studentů a příkladů vyskytujících se v datech.

In [8]:
students    = df.anonymous_user_id.drop_duplicates().values
problem_ids = df.problem_id.drop_duplicates().values

1. Celkový přehled odpovědí, studentů a příkladů

Celkové počty studentů, otázek a příkladů.

In [9]:
print('Počet studentů:              ', len(students))
print('Počet vygenerovaných otázek: ', len(df))
print('Počet zodpovězených otázek:  ', len(df[~df.is_open]))
print('Počet příkladů:              ', len(problem_ids))
print('Počet záporně ohodnocených:  ', len(df[df.score < 0]))
Počet studentů:               677
Počet vygenerovaných otázek:  15310
Počet zodpovězených otázek:   15052
Počet příkladů:               28
Počet záporně ohodnocených:   1013

Další "globální" statistiky.

In [10]:
print(
    'Celková úspěšnost (správně zodpovězené / celkový počet odpovědí): ',
    len(df[(~df.is_open) & (df.is_correctly_answered)]) / len(df[~df.is_open])
)
print('Průměrný počet minut potřebaný k odeslání odpovědi:', df.duration.mean())
print('Medián minut potřebaných k odeslání odpovědi:', df.duration.median())
Celková úspěšnost (správně zodpovězené / celkový počet odpovědí):  0.6783816104172203
Průměrný počet minut potřebaný k odeslání odpovědi: 58.96472032380068
Medián minut potřebaných k odeslání odpovědi: 4.65

Kdy jsou studenti nejaktivnější co se otevírání nových otázek, resp. odesílání odpovědí, a denní hodiny týče?

In [11]:
ax = df.created_at.apply(lambda t: t.hour + t.minute / 60).hist(bins=24, figsize=(10, 5))
ax.set_xlabel('Hodina dne')
ax.set_ylabel('Počet vytvořených otázek')
ax.set_title('Otevírání nových otázek')

plt.xticks(range(25))
plt.show()
In [12]:
ax = df.answered_at.apply(lambda t: t.hour + t.minute / 60).hist(bins=24, figsize=(10, 5))
ax.set_xlabel('Hodina dne')
ax.set_ylabel('Počet odeslaných odpovědí')
ax.set_title('Zodpovídání otázek')

plt.xticks(range(25))
plt.show()

Jak je vytváření otázek závislé na blížícím se deadline?

In [13]:
data = df.to_deadline.apply(lambda t: t / 60)
maxh = int(np.ceil(data.max()))

ax = data.hist(bins=maxh, figsize=(12, 5))
ax.set_xlabel('Počet hodin zbývajících do deadline')
ax.set_ylabel('Počet vytvořených otázek')
ax.set_title('Aktivita studentů během doby přístupnosti kvízu')

plt.xticks(range(0, maxh, 24), rotation=90)
plt.show()

Jak dlouho studentům trvá na otázku odpovědět? Následující graf je ořezaný o několik outlierů (velmi dlouho otevřené otázky).

In [14]:
data = df[df.duration <= 10*df.duration.median()].duration
maxh = int(np.ceil(data.max()))

ax = data.hist(bins=maxh, figsize=(12, 5))
ax.set_xlabel('Počet minut na zodpovězení')
ax.set_ylabel('Počet otázek')
ax.set_title('Doba potřebaná k zodpovězení otázky')

plt.xticks(range(0, maxh, 1), rotation=90)
plt.show()

2. Statistiky příkladů a úspěšnost

Předpočtení statistik jednotlivých příkladů.

In [15]:
# Nový DataFrame
dp = pd.DataFrame(index=problem_ids, columns=[
    'displayed', 'kind', 'answers', 'correct', 'wrong', 'success_rate', 'median_time'
])

# Příklad po příkladu...
for p in problem_ids:
    answers = df[(df.problem_id == p) & (df.is_open == False)]
    
    if pd.isnull(dp.at[p, 'kind']):
        dp.at[p, 'kind'] = answers.iloc[0]['kind']
    
    dp.at[p, 'displayed'] = len(df[df.problem_id == p])
    dp.at[p, 'answers'] = len(answers)
    dp.at[p, 'correct'] = len(df[(df.problem_id == p) & (df.is_open == False) & (df.is_correctly_answered == True)])
    dp.at[p, 'wrong'] = dp.at[p, 'answers'] - dp.at[p, 'correct']
    dp.at[p, 'success_rate'] = dp.at[p, 'correct'] / dp.at[p, 'answers']
    dp.at[p, 'median_time'] = answers.duration.median()

Zobrazení výsledků, seřazeno podle míry úspěšnosti (od "nejtěžšího" příkladu k "nejlehčímu"). Index (první sloupec) je MARAST ID příkladu. V posledním sloupci je uveden medián počtu minut potřebných na zodpovězení otázky.

In [16]:
with pd.option_context('display.max_rows', None, 'display.max_columns', None):
    display(dp.sort_values(by=['success_rate']))
displayed kind answers correct wrong success_rate median_time
3859 576 multichoice 556 222 334 0.399281 5.11667
4549 573 multichoice 565 228 337 0.40354 5.76667
981 552 multichoice 537 233 304 0.433892 4.26667
4551 565 multichoice 553 266 287 0.481013 8.56667
974 553 multichoice 544 265 279 0.487132 5
3857 564 multichoice 552 311 241 0.563406 5.20833
969 558 multichoice 544 314 230 0.577206 6.64167
2490 566 multichoice 558 353 205 0.632616 3.55833
3848 543 multichoice 533 353 180 0.662289 6.73333
3860 542 multichoice 536 358 178 0.66791 5.13333
977 564 multichoice 549 370 179 0.673953 5.8
4550 551 multichoice 543 368 175 0.677716 5.26667
2475 545 multichoice 540 369 171 0.683333 3.2
976 549 multichoice 539 379 160 0.703154 6.18333
979 541 multichoice 532 386 146 0.725564 6.025
3847 537 multichoice 526 387 139 0.735741 5.09167
3854 546 multichoice 537 398 139 0.741155 4.25
1219 527 multichoice 517 384 133 0.742747 3.7
3846 545 multichoice 537 399 138 0.743017 4.76667
978 550 multichoice 536 408 128 0.761194 4.21667
975 542 multichoice 534 407 127 0.762172 3.525
983 545 multichoice 541 416 125 0.768946 2.91667
982 538 multichoice 535 413 122 0.771963 2.78333
972 524 multichoice 516 415 101 0.804264 4.975
3849 541 multichoice 536 447 89 0.833955 4.00833
3851 548 multichoice 542 454 88 0.837638 3
3850 513 multichoice 507 447 60 0.881657 5.5
3852 512 multichoice 507 461 46 0.90927 2.73333

Závisí celková úspěšnost odpovídání na blízkosti deadline?

In [17]:
step = 6 * 60 # 3 hours
frame = pd.DataFrame(columns=['hours_to_deadline','success_ratio'])

for k in range(1, 8 * 7): # one week
    dsnap = df[(df.to_deadline_answer >= (k-1)*step) & (k*step > df.to_deadline_answer)]
    if len(dsnap) > 0:
        frame.at[k, 'hours_to_deadline'] = k*step
        frame.at[k, 'success_ratio'] = len(dsnap[dsnap.is_correctly_answered]) / len(dsnap)

maxt = frame.hours_to_deadline.max()
        
ax = frame.plot(
    x="hours_to_deadline", y="success_ratio",
    kind="bar", grid=True, legend=False
)

ax.set_xlabel('Počet minut na zodpovězení')
ax.set_ylabel('Počet otázek')
ax.set_title('Doba potřebaná k zodpovězení otázky')

plt.xticks(range(0, 8*7, 24), rotation=90)
plt.show()

4. Podrobnější analýza chování

Nejprve musíme extrahovat potřebná data. Zaznamenáme kolik odpovědí student vytvořil, kolik jich potřeboval na splnění kvízu, jak dlouho mu to od otevření kvízu trvalo, kolik času do deadline mu v okamžiku splnění kvízu zbývalo, úspěšnost jeho odpovědí a medián doby potřebné na odeslání jedné odpovědi.

In [18]:
ds = pd.DataFrame(index=students, columns=[
    'total_questions', 'total_score', 'questions_to_success',
    'minutes_to_success', 'minutes_left', 'success_rate', 'median_minutes_to_answer'
])

Nyní samotný výpočet.

In [19]:
for s in students:
    questions = df[df.anonymous_user_id == s].copy()
    
    ds.at[s, 'total_questions'] = len(questions)
    ds.at[s, 'total_score'] = questions.score.sum()
    
    score   = 0.0
    counter = 0
    success = 0
        
    for idx, q in questions.iterrows():
        counter += 1
        score   += q['score']
        
        if q['is_correctly_answered']:
            success += 1
    
        if q['answered_at']:
            questions.at[idx, 'minutes_to_answer'] = (q['answered_at'] - q['created_at']).total_seconds() / 60
    
        if score >= SCORE_TO_PASS:
            ds.at[s, 'questions_to_success'] = counter
            ds.at[s, 'minutes_to_success'] = (q['answered_at'] - questions.iloc[0]['answered_at']).total_seconds() / 60
            ds.at[s, 'minutes_left'] = (deadline - q['answered_at']).total_seconds() / 60
            break
    
    ds.at[s, 'success_rate'] = success / len(questions)
    ds.at[s, 'median_minutes_to_answer'] = questions.minutes_to_answer.dropna().median()

ds.questions_to_success = ds.questions_to_success.fillna(0).astype(np.int64)

Kontrola datových typů.

In [20]:
ds = ds.infer_objects()
ds.dtypes
Out[20]:
total_questions               int64
total_score                   int64
questions_to_success          int64
minutes_to_success          float64
minutes_left                float64
success_rate                float64
median_minutes_to_answer    float64
dtype: object

Náhled dat, z očividných důvodů nezobrazujeme všechny.

In [21]:
ds
Out[21]:
total_questions total_score questions_to_success minutes_to_success minutes_left success_rate median_minutes_to_answer
6dbaf97c226b4933b3869709eb5087b5 15 14 0 NaN NaN 0.933333 5.033333
7251e18e655dba3171cdff48851ed5ee 11 9 0 NaN NaN 0.818182 3.550000
cd15b89a994950965eaacb230b62af1e 19 15 19 1331.550000 1381.816667 0.789474 2.566667
3310a39c68523b06d781564c4192c1f1 15 15 15 72.666667 3197.816667 1.000000 3.350000
fa45ff5817dbed647d0c1fca4cbd6d88 22 14 0 NaN NaN 0.681818 6.241667
... ... ... ... ... ... ... ...
dfd2bdd932f91ca8ffff35e336073823 12 5 0 NaN NaN 0.583333 4.183333
d2c3aa1eae532ba593b91fb127823e95 8 3 0 NaN NaN 0.375000 8.683333
58549cb89d113522339bd8a9e122ec94 8 5 0 NaN NaN 0.625000 8.683333
5b8148f422e7e5a8135945d56ecdb2d6 2 0 0 NaN NaN 0.000000 4.475000
304d4a7e96e05ece59121923b707f314 3 2 0 NaN NaN 0.666667 2.650000

677 rows × 7 columns

Zcela základní ukazatale.

In [22]:
print("Počet studentů:      ", len(ds))
print("Z nich kvíz splnilo: ", len(ds[ds.total_score >= SCORE_TO_PASS]))
Počet studentů:       677
Z nich kvíz splnilo:  529

Studenti s nejvíce otázkami.

In [23]:
ds.sort_values(by='total_questions', ascending=False).head(10)
Out[23]:
total_questions total_score questions_to_success minutes_to_success minutes_left success_rate median_minutes_to_answer
daee92f164181d4b17d73d6ea7eb3bc5 93 2 0 NaN NaN 0.258065 1.383333
e298f7de7d3856a0e3f7382d8e8f061e 87 8 0 NaN NaN 0.321839 0.800000
3ff0cc0dbc0e7eda77e391911a676f37 81 13 0 NaN NaN 0.345679 5.800000
a2ce94f81be0cb0626760bf93843708e 78 8 0 NaN NaN 0.333333 1.641667
b7d09623c5e01909b03ab524bc83cd77 74 10 0 NaN NaN 0.351351 4.500000
734ccf5dd370319044e5dc8b32bcd1c9 63 24 49 2993.883333 14603.600000 0.349206 3.250000
e6641a830c5cd02f371a73758098c508 62 3 0 NaN NaN 0.338710 1.083333
41cdf2cd861bab46827a73fa847b50e6 62 30 23 4880.416667 14618.383333 0.241935 2.100000
d664ece36c67ad91420eae955df88532 61 30 41 10511.183333 7660.966667 0.327869 6.450000
4d7a54895546bfe37774fb894908dd24 59 15 59 3533.416667 114.316667 0.474576 2.350000

Kolik otázek studenti potřeboval ke splnění kvízu?

In [24]:
data = ds[ds.questions_to_success > 0].questions_to_success
maxo = int(data.max())

ax = data.hist(bins=(maxo+1), figsize=(10, 5))
ax.set_xlabel('Počet otázek')
ax.set_ylabel('Počet studentů')
ax.set_title('Počet otázek potřebných ke splnění kvízu')

plt.xticks(range(0, maxo, SCORE_TO_PASS), rotation=90)
plt.show()

Další statistiky počtu otázek potřebných ke splnění kvízu.

In [25]:
print(
    'Průměrný počet otázek potřebných ke splnění kvízu:',
    ds[ds.questions_to_success > 0].questions_to_success.mean()
)
print(
    'Medián počtu otázek potřebných ke splnění kvízu:',
    ds[ds.questions_to_success > 0].questions_to_success.median()
)
print(
    'Maximum počtu otázek potřebných ke splnění kvízu:',
    ds[ds.questions_to_success > 0].questions_to_success.max()
)
print(
    'Minimum počtu otázek potřebných ke splnění kvízu:',
    ds[ds.questions_to_success > 0].questions_to_success.min()
)
Průměrný počet otázek potřebných ke splnění kvízu: 22.75992438563327
Medián počtu otázek potřebných ke splnění kvízu: 21.0
Maximum počtu otázek potřebných ke splnění kvízu: 59
Minimum počtu otázek potřebných ke splnění kvízu: 15

Kolik času (od prvního otevření kvízu po jeho splnění) studentům řešení kvízu zabralo? Toto je hrubý nástřel, typicky bude obsahovat i noční dobu atp. I tak je ale výsledek zajímavý.

In [26]:
data = ds[ds.minutes_to_success > 0].minutes_to_success.apply(lambda t: t / 60)
maxd = int(np.ceil(data.max()) / 2)

ax = data.hist(bins=maxd, figsize=(18, 5))
ax.set_xlabel('Čas v hodinách')
ax.set_ylabel('Počet studentů')
ax.set_title('Čas potřebný ke splnění kvízu')

plt.xticks(range(0, 2*maxd, 4), rotation=90)
plt.show()

Kolik času studentům zbývalo do deadline v okamžik splnění kvízu?

In [27]:
data = ds[ds.minutes_to_success > 0].minutes_left.apply(lambda t: t / 60)
maxh = int(np.ceil(data.max()) / 2)

ax = data.hist(bins=maxh, figsize=(12, 5))
ax.set_xlabel('Počet hodin zbývajících do deadline')
ax.set_ylabel('Počet studentů')
ax.set_title('Kolik času zbývalo do deadline v okamžik splnění kvízu?')

plt.xticks(range(0, 2*maxh, 24), rotation=90)
plt.show()

Histogram úspěšnosti odpovídání.

In [28]:
ax = ds.success_rate.hist(bins=50, figsize=(10, 5))
ax.set_xlabel('Úspěšnost')
ax.set_ylabel('Počet studentů')
ax.set_title('Rozložení úspěšnosti odpovídání studentů')

plt.show()

Histogram mediánu času potřebného k odeslání odpovědi od vygenerování otázky.

In [29]:
ax = ds[ds.median_minutes_to_answer <= 60 * 6].median_minutes_to_answer.hist(bins=48, figsize=(10, 5))

ax.set_xlabel('Medián doby potřebné na zodpovězení (minuty)')
ax.set_ylabel('Počet studentů')
ax.set_title('Rozložení doby potřebné na zodpovězení')

plt.show()

Porovnání úspěšnosti odpovídání a času zbývajícího do deadline při splnění kvízu.

In [30]:
ax = ds.dropna(how='any', subset=['success_rate','minutes_left']).plot.hexbin(
    x='success_rate', y='minutes_left', gridsize=25, figsize=(10,7), sharex=False
)
ax.set_xlabel('Úspěšnost')
ax.set_ylabel('Čas zbývající do deadline (minuty)')
ax.set_title('Úspěšnost vs. čas zbývající do deadline')

plt.show()

Porovnání úspěšnosti odpovídání a mediánu doby potřebné k odeslání jedné odpovědi.

In [31]:
ax = ds[ds.median_minutes_to_answer <= 15].dropna(how='any', subset=['success_rate','median_minutes_to_answer']).plot.hexbin(
    x='success_rate', y='median_minutes_to_answer', gridsize=25, figsize=(10,7), sharex=False
)
ax.set_xlabel('Úspěšnost')
ax.set_ylabel('Medián doby na odpověď (minuty)')
ax.set_title('Úspěšnost vs. medián doby potřebné k zodpovězení')

plt.show()

5. Analýza počtu "sezení"

Rozšíříme tabulku studentů o další sloupce, pomocí clusterování se snažíme zjistit na kolik "sezení" student kvíz splnil.

In [32]:
for idx, row in ds.iterrows():
    answers = df[df.anonymous_user_id == idx].copy()
    
    if len(answers) <= 1:
        continue
    
    data = np.reshape(answers.to_deadline.values, (len(answers), 1))
    clusters = fclusterdata(data, t=CLUSTER_THRESHOLD, criterion='distance')
    answers['clusters'] = clusters
    
    ds.at[idx, 'nclusters'] = np.max(clusters)

ds.nclusters = ds.nclusters.fillna(0).astype(np.int64)

Hrubý náhled.

In [33]:
ds
Out[33]:
total_questions total_score questions_to_success minutes_to_success minutes_left success_rate median_minutes_to_answer nclusters
6dbaf97c226b4933b3869709eb5087b5 15 14 0 NaN NaN 0.933333 5.033333 1
7251e18e655dba3171cdff48851ed5ee 11 9 0 NaN NaN 0.818182 3.550000 1
cd15b89a994950965eaacb230b62af1e 19 15 19 1331.550000 1381.816667 0.789474 2.566667 3
3310a39c68523b06d781564c4192c1f1 15 15 15 72.666667 3197.816667 1.000000 3.350000 1
fa45ff5817dbed647d0c1fca4cbd6d88 22 14 0 NaN NaN 0.681818 6.241667 6
... ... ... ... ... ... ... ... ...
dfd2bdd932f91ca8ffff35e336073823 12 5 0 NaN NaN 0.583333 4.183333 1
d2c3aa1eae532ba593b91fb127823e95 8 3 0 NaN NaN 0.375000 8.683333 1
58549cb89d113522339bd8a9e122ec94 8 5 0 NaN NaN 0.625000 8.683333 1
5b8148f422e7e5a8135945d56ecdb2d6 2 0 0 NaN NaN 0.000000 4.475000 1
304d4a7e96e05ece59121923b707f314 3 2 0 NaN NaN 0.666667 2.650000 1

677 rows × 8 columns

Studenti nejvíce se vracející.

In [34]:
ds.sort_values(by='nclusters', ascending=False).head(10)
Out[34]:
total_questions total_score questions_to_success minutes_to_success minutes_left success_rate median_minutes_to_answer nclusters
a2ce94f81be0cb0626760bf93843708e 78 8 0 NaN NaN 0.333333 1.641667 20
b7d09623c5e01909b03ab524bc83cd77 74 10 0 NaN NaN 0.351351 4.500000 17
daee92f164181d4b17d73d6ea7eb3bc5 93 2 0 NaN NaN 0.258065 1.383333 13
d08cf0b8392e9b7844013017175ecf18 25 15 24 20470.000000 6439.233333 0.640000 16.058333 12
daa181337f9fb25b5122ef9df114446d 47 30 17 12832.500000 10564.066667 0.340426 9.416667 12
a3cd8f7f3501b39812a8045bd00d9737 39 30 15 5682.800000 1727.000000 0.384615 7.733333 12
db7cd2a5b1591ffbfb6a1473a3978a60 28 16 26 24487.250000 189.366667 0.535714 11.800000 11
055696c84942aa2fd6dbbf95b4ca65f5 38 30 21 2425.850000 19458.133333 0.421053 11.616667 11
9e30406afccf16ae8fce4865dd865063 58 30 27 11508.583333 14588.783333 0.293103 6.016667 11
5d53a0e1aa4306599945c7175c4eb263 20 10 0 NaN NaN 0.550000 23.316667 11
In [35]:
data = ds.nclusters
maxc = data.max()

ax = data.hist(bins=maxc, figsize=(10, 5))
ax.set_xlabel('Počet "sezení"')
ax.set_ylabel('Počet studentů')
ax.set_title('Na kolik "sezení" studenti kvíz vyplnili')

plt.show()

Porovnání ve vztahu k úspěšnosti.

In [36]:
ax = ds[ds.median_minutes_to_answer <= 15].dropna(how='any', subset=['success_rate','nclusters']).plot.hexbin(
    x='success_rate', y='nclusters', gridsize=25, figsize=(10,7), sharex=False
)
ax.set_xlabel('Úspěšnost')
ax.set_ylabel('Počet "sezení"')
ax.set_title('Úspěšnost vs. počet "sezení"')

plt.show()