Upload
others
View
1
Download
0
Embed Size (px)
Citation preview
Faculteit Wetenschappen
Vakgroep Toegepaste Wiskunde, Informatica en Statistiek
Computationele benaderingen voordeductie van de computationele
complexiteit van computerprogramma’s
Evert Van Petegem
Promotor: prof. dr. Peter Dawyndt
Masterproef ingediend tot het behalen van de academische graad van
Master of Science in de Informatica
Academiejaar 2017-2018
Dankwoord
Deze thesis was het grootste onderzoeksproject van mijn academische carrière tot nu toe.
Ik ben zeer blij met de kans die ik heb gekregen om bij te dragen aan de manier waarop
onderwijs wordt gegeven aan mijn eigen opleiding via deze thesis. Ik wil graag mijn
promotor, prof. dr. Peter Dawyndt, bedanken voor zijn steun en hulp bij het uitwerken
ervan. Zelfs wanneer ik in het eerste semester 1400 kilometer ver was op Erasmus nam
hij systematisch de tijd om mij te begeleiden.
Verder wil ik ook mijn vrienden en familie bedanken, zonder wiens steun deze thesis er
zeer anders had uitgezien. Naast hun eindeloze emotionele steun appreciëer ik ook sterk
hun luisterend oor terwijl ik praatte over de problemen en successen die ik tijdens het
uitwerken van deze thesis ben tegengekomen.
i
Abstract
Voor het online leerplatform Dodona was een gewenste feature geïdentificeerd om het
nemen van accurate tijds- en geheugenmetingen toe te voegen. Daarnaast wilden we ook
de computationele complexiteit van ingediende oplossingen inschatten. In deze thesis
zullen we een methode beschrijven om automatisch tijds- en geheugenmetingen te nemen
en op basis van deze metingen de computationele complexiteit van programma’s in te
schatten. Om onze methode te testen, hebben we teruggegrepen naar oefeningen die in
Dodona aanwezig waren. Zo weten we meteen of onze methode ook echt bruikbaar zal
zijn binnen Dodona. Daarnaast hebben we een bevraging uitgevoerd bij studenten naar
de wijze waarop zij de metingen zouden gebruiken.
Niet-technische synopsis
De opleiding Informatica aan de faculteit Wetenschappen van de Universiteit Gent maakt
gebruik van Dodona om automatisch de code die studenten indienen te testen. Momenteel
wordt er enkel op correctheid getest en niet op performantie. In deze thesis ontwikkelen
we twee methoden om naast de correctheid ook de performantie van de code te testen.
De eerste methode voert metingen uit tijdens de uitvoering van de code. We kijken
waar in de code veel tijd gespendeerd wordt of veel geheugen gebruikt wordt. Hier kunnen
we dan feedback over geven aan de student, zodat ze een idee heeft van hoe performant
haar code is.
De tweede methode probeert de computationele complexiteit van de code in te schat-
ten. Computationele complexiteit is een theoretisch model dat een ruw idee geeft van
hoeveel langer de code zal uitvoeren (of hoeveel meer geheugen de code zal gebruiken)
naarmate de grootte van de inputs toeneemt. We schatten deze computationele com-
plexiteit in door automatisch een reeks experimenten uit te voeren waarbij de code steeds
andere inputs krijgt (met ook telkens andere groottes).
ii
Extended abstract
Introduction
Dodona (dodona.ugent.be) is an intelligent tutoring system that aims at using a variety
of computing technologies to provide immediate feedback in the context of learning com-
puter programming. The actual feedback is generated by judges and assigment-specific
software tests that specify how solutions can be automatically generated. Traditionally,
each programming language has its own judge. At the moment, Dodona only provides
feedback regarding the correctness of the output of the submitted code.
This thesis implements two methods that can be used to provide feedback regarding
the performance of the submitted code. The first method consists of simply measuring
how much time the execution of the code took and how much memory was used while
executing the code. The second method inferences the computational complexity of the
submitted code from a series of experiments where the time spent executing and the
maximum memory usage were measured using the implementation of the first method.
When implementing these methods, we want to make sure that they are as program-
ming language independent as possible, so that we don’t have to reimplement them for
each individual judge.
Performance measurements
To implement the automatic measurements we used Jupyter kernels. Jupyter kernels
are the technology behind Jupyter Notebooks. Using the Jupyter kernels allows us to
easily support multiple programming languages. The Jupyter framework offers a uniform
interface to interact with the kernels. This drastically reduces the need for programming
language-specific functionality. Because the Jupyter framework is written in Python and
thus offers the most support for Python we chose to create our implementation in Python.
We discern two ways of measuring the performance. The first way is inside the lan-
guage. This means that we use existing language tools for measuring the performance. We
created an implementation for Python based on the existing mem_profiler module. We
monitor the memory usage per executed line of code. Because we also save a timestamp
when measuring the memory usage we implicitely save all the data needed to measure the
time per executed line of code.
iii
Figure 1: Memory measurements outside (left) and inside (right) the language. The red
lines on the graph for the measurements inside the language show the execution of the
functions within the program.
The second way of measuring the performance is outside the language. We use oper-
ating system tools to measure the memory usage of the Jupyter kernel at a fixed interval.
Measuring the execution time is simply saving the time difference between the start of
the execution and the end of the execution.
Measuring the performance inside the language gives us much more information to
construct the feedback we give to the student. For example, we can connect memory usage
to function calls in a graphical display of the memory footprint during code execution. It
is also obvious that measuring the performance inside the language incurs a significant
implementation cost for each supported programming language. Another downside of
measuring inside the language is that is significantly slower.
Figure 1 shows examples of graphs that can be created using the measurements from
outside the language and inside the language.
Inferencing computational complexity
To infer the computational complexity we run a series of experiments during which we
measure the execution time and the maximum memory usage for each experiment. For
each experiment we also record the input sizes. We use a list of complexity functions
defined by the exercise. This allows us to support arbitrary complexity functions.
To select the most likely complexity function we compute linear regressions between
the results of the complexity functions applied to the input sizes and the measurements.
The regression with the highest correlation coefficient is the one we pick as the most likely.
iv
To determine which input sizes the experiments are run with, we start with initial
input sizes defined by the exercise. Using the measurements of this experiment we use
the linear regressions to predict future measurements. Then we select input sizes so that
(with these predictions added) the complexity functions do not correlate with each other.
The selected input sizes are then used for experiments. This is repeated until we are left
with one complexity function that correlates with the measurements.
For one of the tests of our implementation we used an exercise from a Computational
Biology course. The exercise was to compute an aligment in linear space. The time
complexity of the algorithm that needed to be implemented was O(n2). We created 4
implementations with different memory complexities. Given enough time our implement-
ation was able to correctly identify the memory complexities of these 4 programs. For each
of the 4 programs our implementation was also able to identify the O(n2) time complexity.
A limitation that we were not able to resolve is that for complexity functions smaller
than O(n) there is too much overhead from the Jupyter kernel which makes it so that our
implementation is unable to identify them.
Future work
Based on the research done for this thesis we have submitted grant applications for a doc-
torate that will focus on creating a universal judge for Dodona. Based on the experience
we have gained while working on this thesis we are confident that we can implement a
judge that will require minimal effort to support multiple programming languages. At
first, each exercise will need to declare its programming language. At a further stage in
the doctorate we will allow solutions for a single programming assigment to be submitted
in multiple programming languages.
Conclusion
We were able to implement programming language independent methods to measure the
performance of a program and infer its computational complexity.
v
Inhoudsopgave
1 Inleiding 1
1.1 Achtergrond . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.2 Opzet . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.2.1 Uitvoeren van tijds- en geheugenmetingen . . . . . . . . . . . . . . 2
1.2.2 Inschatten van de computationele complexiteit . . . . . . . . . . . . 3
2 Inschatten van computationele complexiteit 4
2.1 Statische analyse van de broncode . . . . . . . . . . . . . . . . . . . . . . . 4
2.2 Complexiteit inschatten op basis van metingen . . . . . . . . . . . . . . . . 5
3 Werkwijze 11
3.1 Metingen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
3.1.1 Binnen de taal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
3.1.2 Buiten de taal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
3.1.3 Vergelijking . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
3.1.4 Overhead . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
3.2 Inschatten computationele complexiteit . . . . . . . . . . . . . . . . . . . . 14
3.2.1 Eerste implementatie . . . . . . . . . . . . . . . . . . . . . . . . . . 14
3.2.2 Tweede implementatie . . . . . . . . . . . . . . . . . . . . . . . . . 15
4 Resultaten 20
4.1 Geheugenmetingen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
4.2 Inschatten complexiteit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
4.2.1 Eerste implementatie . . . . . . . . . . . . . . . . . . . . . . . . . . 23
4.2.2 Tweede implementatie . . . . . . . . . . . . . . . . . . . . . . . . . 24
4.3 Feedback van studenten . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
vi
5 Toekomstig werk 47
5.1 Probleemstelling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
5.2 Doelstelling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
5.3 Methodologie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
6 Conclusie 53
vii
Hoofdstuk 1
Inleiding
In deze thesis willen we een methode ontwikkelen om automatisch tijds- en geheugenme-
tingen te nemen van uitvoeringen van computerprogramma’s. Verder willen we met deze
opgebouwde infrastuctuur om metingen te nemen automatisch de complexiteit van deze
computerprogramma’s inschatten. Meer specifiek willen we dit doen in een context voor
het testen van programma’s ingediend door studenten. We bespreken eerst de achtergrond
waaruit deze thesis is voortgekomen. Dan gaan we verder met een uitgebreidere uitleg van
het nut van de tijds- en geheugenmetingen en waarom we de complexiteit willen kunnen
inschatten.
1.1 Achtergrond
Deze thesis is voortgekomen uit het online leerplatform Dodona (dodona.ugent.be) dat
de Universiteit Gent ontwikkelt. Dodona is een platform voor studenten en docenten
dat op een didactisch onderbouwde manier zo snel en zo goed mogelijk feedback geeft.
Hierbij is het belangrijk om de feedback loop voor programmeeropdrachten te automati-
seren. Dodona werd opgezet als een generiek raamwerk. Docenten kunnen eigen scripts
definieren die de workflow vastleggen om ingediende oplossingen te beoordelen (judges
genaamd). Het is gebruikelijk om voor één programmeertaal één judge te hebben. Elke
judge ondersteunt ook maar één programmeertaal.
Dodona kan zowel ingezet worden bij examens als bij oefensessies. Studenten kunnen
onbeperkt oplossingen indienen, waarop ze telkens feedback krijgen over de correctheid.
De snelheid waarmee studenten deze feedback krijgen is belangrijk. Hoe sneller de feed-
1
back bij de student geraakt, hoe sneller de student aan het werk kan gaan om eventuele
problemen op te lossen.
Momenteel bevatten de judges die Dodona gebruikt weinig ondersteuning om de per-
formantie van de code te beoordelen. De testen die Dodona uitvoert hebben altijd een
tijdslimiet. Effectieve uitvoeringstijd wordt gemeten, maar deze metingen worden niet in
de feedback verwerkt en voor de rest ook niet gebruikt.
1.2 Opzet
Gegeven de ervaring die met Dodona is opgedaan, willen we een uitbreiding maken om
ook de performantie van code te testen en hierop feedback te geven. We willen dit liefst
zo programmeertaalonafhankelijk mogelijk doen, zodat we deze functionaliteit niet per
judge opnieuw moeten implementeren. We onderscheiden twee delen aan het testen van
de performantie en de feedback die we hierover willen geven. Enerzijds is er het uitvoerven
van de tijds- en geheugenmetingen, anderzijds is er het inschatten van de computationele
complexiteit.
1.2.1 Uitvoeren van tijds- en geheugenmetingen
Er zijn een aantal redenen waarom we tijds- en geheugenmetingen willen doen en hierover
feedback willen geven aan studenten en docenten.
Voor studenten geeft dit (zeker voor geheugengebruik) visuele feedback over het ge-
drag van hun programma. Ze kunnen zien waar hun programma veel tijd spendeert of
waar het veel geheugen verbruikt. Dit kan helpen om hun programma’s te optimaliseren.
Daarnaast laat het ook gamification van de programmeeropdracht toe. Dit kan nu al
deels in Dodona: studenten kunnen zien hoeveel van hun medestudenten een oefening
reeds juist hebben opgelost. De tijds- en geheugenmetingen kunnen dit echter nog een
stuk versterken. In plaats van als eerste een juiste oplossing te proberen vinden, kunnen
studenten ook proberen om de snelste oplossing te maken of de oplossing die het minste
geheugen verbruikt. In het vak Computationele Biologie uit de Bachelor Informatica is
in academiejaar 2017–2018 hiermee al geëxperimenteerd: de code van de studenten werd
periodiek getest voor een selectie van oefeningen en een rangschikking op basis van de
uitvoeringstijd en het maximale geheugenverbuik werd online gezet. Dit motiveerde vele
2
studenten om hun oplossing te blijven verbeteren. Het automatiseren van deze metingen
en het aanbieden van een live leaderboard kan deze competiviteit bij de studenten enkel
maar doen toenemen.
Docenten kunnen deze metingen gebruiken om de uitvoering van hun onderwijstaak te
verbeteren. Door eenvoudig te zien wat de tragere of minder geheugenefficiënte oplossin-
gen zijn, kunnen docenten gemakkelijker vaak voorkomende fouten opsporen en hierover
feedback geven aan de studenten. Voor vakken waar de efficiëntie van code relevant is,
kan dit ook een hulp zijn om de code van de studenten te beoordelen.
1.2.2 Inschatten van de computationele complexiteit
Een informele definitie van de computationele complexiteit van een programma is dat de
computationele complexiteit een theoretisch model is dat aangeeft hoe het gebruik van een
resource door een programma zal toenemen naarmate de grootte van de inputs toeneemt.
De twee meest gebruikte resources (en ook degene waar wij ons op zullen toeleggen) zijn
tijd en geheugen.
Het inschatten van de computationele complexiteit van een programma gebeurt door-
gaans op basis van een theoretische analyse. Wij willen deze computationele complexiteit
op een geautomatiseerde manier proberen inschatten. Dit laat ons toe om te testen of een
student een algoritme juist geïmplementeerd heeft. Hier betekent een juiste implementatie
niet alleen dat het de correcte resultaten teruggeeft, maar uiteraard ook dat de computa-
tionele complexiteit van de implementatie dezelfde is als de computationele complexiteit
van het algoritme. Voor vakken die zich toespitsen op algoritmen en datastructuren kan
dit gebruikt worden om het inzicht van de student te testen. Als een student een bepaald
algoritme of een bepaalde datastructuur niet juist kan implementeren, heeft de student
het idee van het algoritme waarschijnlijk niet begrepen.
3
Hoofdstuk 2
Inschatten van computationele
complexiteit
In de wetenschappelijke literatuur zijn er al methoden beschreven voor het inschatten van
de complexiteit van computerprogramma’s. Deze methoden zijn gebaseerd op verschil-
lende principes.
2.1 Statische analyse van de broncode
Het eerste type van methoden dat we hebben bekeken, is gebaseerd op het statisch ana-
lyseren van de broncode. We kunnen al meteen zien dat dit niet optimaal is, aangezien
we liefst zo onafhankelijk mogelijk van de programmeertaal willen werken.
Bij Healy et al. (2000) is de voorgestelde methode meer toegespitst op real-time syste-
men. Er wordt een methode uitgewerkt om het minimum en het maximum aantal iteraties
te bepalen van lussen met meerdere uitgangen. Dit minimum en maximum aantal iteraties
kan zowel symbolisch als numeriek bepaald worden. Voor de numerieke uitwerking moet
de gebruiker zelf waarden aanleveren voor variabelen waarvan de minima en maxima niet
statisch bepaald kunnen worden. De methode ondersteunt ook lussen waarvan de nesting
niet rechthoekig is (zoals bvb. in Listing 2.1).
Ook in het domein van de functionele programmeertalen zijn er al methoden ontwik-
keld om de complexiteit van programma’s in te schatten. In Danielsson (2008) wordt er
een bibliotheek ontwikkeld waarbij de gebruiker aantekeningen moet maken in de code,
om met deze aantekeningen via het typesysteem een model te maken van het aantal uit-
4
1 for i in range ( 1 0 0 ) :
2 for j in range ( i ) :
3 print ( i , j )
Listing 2.1: Voorbeeldcode voor niet-rechthoekige lussen
gevoerde operaties in de code. Deze bibliotheek maakt gebruik van afhankelijke types.
Als een waarde het type Thunk n a heeft, kan een weak head normal form van de waarde
a in n stappen bekomen worden (geamortiseerd). De annotaties die door de gebruiker in
de code geplaatst moeten worden, zijn functies van het type Thunk n a → Thunk (1 +
n) a. Thunk is hierbij een identiteitsmonad.
Al deze methoden hebben echter hetzelfde probleem: ze verwachten een extra in-
spanning van de gebruiker. Daarnaast zijn ze ook niet programmeertaalonafhankelijk.
Aangezien we een tool willen maken die gebruikt kan worden zonder de code te moeten
aanpassen of veel te moeten weten van de programmeertaal, kunnen we hier dus weinig
uit gebruiken.
2.2 Complexiteit inschatten op basis van metingen
In McGeoch et al. (2002) worden enkele moeilijkheden besproken die het experimenteel
inschatten van de complexiteit gebaseerd op metingen theoretisch onmogelijk maken en
in de praktijk zo goed als altijd een stuk lastiger maken.
Zo is er bijvoorbeeld het probleem dat er te veel mogelijke inputs zijn, zelfs voor een
begrensde grootte van de input. Het exhaustief testen van alle inputs is ofwel onmogelijk
(als er oneindig veel mogelijke inputs zijn) of zeer onpraktisch (aangezien we ook resultaten
willen hebben op een redelijke termijn). Om met dit probleem rekening te houden, wordt
er gebruikt gemaakt van randomisatie van de inputs. Dit kunnen we gebruiken om een
hypothese over alle instanties om te zetten in een hypothese over het gemiddelde geval.
Een volgend probleem is dat de inputgrootte vaak ook niet begrensd is. Hierdoor is
elke inschatting van de complexiteit theoretisch gezien inherent onbetrouwbaar. Neem
bijvoorbeeld een algoritme dat O(n) stappen nodig heeft voor een inputgrootte van n <
106. Als het algoritme voor een inputgrootte van n > 106 ineens kwadratisch wordt
(omdat bijvoorbeeld voor n < 106 het algoritme enkele shortcuts kan nemen) en we
5
hebben geen gevallen van deze grootte getest, dan is het al meteen onmogelijk om de
complexiteitsgrens juist in te schatten.
Ook het omgekeerde geval is een probleem. Als er geclaimd wordt dat een algoritme een
lineaire complexiteit heeft, maar de metingen zijn duidelijk kwadratisch, dan kan er altijd
gezegd worden dat de metingen niet ver genoeg gegaan zijn. Een makkelijk te construeren
voorbeeld hiervan is een typische implementatie van quicksort. Bij de meeste implemen-
taties wordt er vaak overgegaan naar een simpeler kwadratisch algoritme van zodra de
opgesplitste lijsten klein genoeg zijn. Deze implementaties hebben nog steeds complexiteit
O(nlog(n)), omdat de threshold om naar een kwadratisch sorteeralgoritme over te stappen
constant gekozen wordt en de uitvoeringen van het kwadratisch sorteeralgoritme voor de
complexiteit van de quicksort implementatie dus als constante meegerekend wordt. Als we
deze threshold zeer groot kiezen (bijvoorbeeld 106), dan zullen de metingen ook duidelijk
kwadratisch zijn voor inputs van een redelijke grootte. De complexiteit van het quicksort
algoritme is echter nog steeds O(nlog(n)).
Verder is ook de complexiteit van het machinemodel een probleem. Moderne com-
puters maken tijdsmetingen onbetrouwbaarder. Zo hebben bijvoorbeeld caches, virtueel
geheugen, out-of-order execution, compilers en time-sharing van processen allemaal een
invloed op het uitvoeren van programma’s en maken het dus moeilijker om de uitvoe-
ringstijd in te schatten. Hierdoor zit er een significante variantie op de metingen die we
doen. Zelfs geheugenmetingen worden bemoeilijkt. De meeste talen bevatten tegenwoor-
dig een runtime met een garbage collector. Dit verhoogt het programmeergemak, maar
deze garbage collectors en de momenten waarop ze geactiveerd worden zijn meestal niet-
deterministisch. Twee verschillende uitvoeringen van hetzelfde programma met dezelfde
inputs kunnen dus een verschillend maximum geheugengebruik hebben.
McGeoch (1987) bevat een korte bespreking van enkele technieken om de variantie op
de meetresultaten te verminderen. De eerste (en eenvoudigste) techniek om de variantie op
de meetresultaten te verminderen, is om per meetpunt meerdere metingen uit te voeren.
Dit laat toe om dan bijvoorbeeld een gemiddelde van de resultaten te nemen, wat dan
ook een betere inschatting zal zijn voor de gemiddelde duur van een uitvoering van het
programma met de gegeven inputs.
Het laatste probleem dat in McGeoch et al. (2002) wordt aangebracht, is de moei-
lijkheid om hypotheses te vinden. De gemeten functie van de uitvoeringstijden kan non-
6
monotonisch zijn, terwijl we voor de complexiteitsgrenzen enkel geïnteresseerd zijn in een
monotonische bovengrens. Ook zijn voor kleine inputgroottes de termen van lagere orde
zeker niet te verwaarlozen.
Gulwani et al. (2009) ontwikkelden de tool SPEED om de computationele complexiteit
van programma’s te bepalen. SPEED is gebaseerd op automatische instrumentatie van
de code door meerdere tellers in de code te plaatsen. Met een tool die lineaire invarianten
genereert, berekent SPEED lineaire grenzen per individuele teller. Deze individuele gren-
zen worden dan samengesteld om totale grenzen te genereren die niet-lineair en disjunct
kunnen zijn. Er wordt ook een algoritme gegeven om het genereren van deze grenzen te
automatiseren. Om complexiteitsgrenzen in te schatten voor andere resources dan tijd,
is de methode bruikbaar zolang de gebruiker goede tellers kan instellen. Ook deze me-
thode was voor ons niet direct bruikbaar. Aangezien de code aangepast moet worden om
de tellers er in te kunnen plaatsen, is het per programmeertaal vrij veel werk om dit te
ondersteunen.
Een al iets meer programmeertaalonhafhankelijke methode werd uitgewerkt in Golds-
mith et al. (2007). Deze methode werkt met profilers om de kost per basic block te
meten. Deze kost wordt uitgedrukt in het aantal keer dat het basic block wordt uitge-
voerd. De gebruiker moet de argumenten voor de verschillende uitvoeringen in geven.
Ook de verschillende numerieke kenmerken waarop de complexiteit kan gebaseerd zijn,
moet de gebruiker voor elke set argumenten ingeven. De basic blocks worden dan ge-
clustered. Clusters zijn verzamelingen van basic blocks waarin met een lineair model het
aantal uitvoeringen voorspeld kan worden uit het aantal uitvoeringen van een ander basic
block in de verzameling. Op deze clusters worden dan lineaire modellen en powerlaw mo-
dellen toegepast. Hieruit volgt dat deze methode enkel complexiteitsfuncties van de vorm
O(xa) kan inschatten (waarbij a ingeschat wordt). Om de kwaliteit van het model in te
schatten, wordt gebruikt gemaakt van de R2 statistiek (een meting van de fitness van de
data tegenover het model). Deze methode is zoals gezegd al iets meer onafhankelijk van
de programmeertaal, aangezien de code zelf niet geanalyseerd of aangepast moet worden.
Per programmeertaal is er toch nog wat werk nodig om de werking tussen de profiler
en het programmeeronafhankelijke inschatten te implementeren. Elke programmeertaal
heeft zijn eigen profilers en er zal dus ook werk nodig zijn om deze profilers aan te spreken
en er de relevante data uit te halen.
7
In McGeoch et al. (2002) worden vijf technieken aangebracht om van experimentele
metingen tot een complexiteitsgrens te komen. Deze technieken zijn gebaseerd op verschil-
lende bestaande statistische en numerieke methoden. In de uitleg over deze technieken die
volgt, duidt f de in te schatten functie aan en f de schatting van de functie op basis van
de gebruikte technieken. De inputgroottes en de metingen van de experimenten worden
respectievelijk voorgesteld als X en Y .
De eerste techniek is de “Guess Ratio” techniek. Deze techniek kan enkel gebruikt
worden voor f(x) van de vorm f(x) = a1xb1 + a2x
b2 + . . . + anxbn . Hierbij wordt er
een functie geraden van de vorm f(x) = xb. De verhouding f(x)/f(x) heeft dan enkele
eigenschappen:
• Wanneer f(x) ∈ O(f(x)) (met andere woorden, f(x) bevindt zich in dezelfde com-
plexiteitsklasse als f(x)) zal deze verhouding afnemen naar een positieve constante
wanneer x toeneemt.
• Wanneer f(x) /∈ O(f(x)) zal de verhouding op een bepaald moment beginnen toe-
nemen en een uniek minimum hebben op een locatie xr.
De “Guess Ratio” techniek maakt gebruik van deze eigenschappen. De verhouding
van Y/f(X) wordt bekeken. Als hier een stijging wordt waargenomen, weten we dat de
eigenschap f(x) /∈ O(f(x)) geldt. De techniek begint met een constante functie f(x) = x0.
Als de tweede eigenschap geldt wordt b met een kleine δ verhoogd. Dit wordt gedaan tot
de tweede eigenschap niet meer geldt. Hieruit moet dan volgen dat de eerste eigenschap
geldt en kunnen we dus concluderen dat f(x) ∈ O(f(x)). Aangezien we beginnen van
f(x) = x0 is het logisch dat hier een ondergrens voor de complexiteitsfunctie zal uitkomen.
De volgende voorgestelde techniek is de “Guess Difference” techniek. Deze techniek is
gelijkaardig aan de “Guess Ratio” techniek maar in plaats van de verhouding Y/f(x) te
gebruiken, wordt er gebruikt gemaakt van het verschil f(X)− Y . Deze techniek zal ook
een bovengrens inschatten in plaats van een ondergrens. De techniek schat functies van
de vorm f(x) = cxd + e in. Het basisidee van de techniek is dat wanneer f(x) /∈ O(f(x))
de kromme van het verschil op een punt moet toenemen (van zodra x groot genoeg is).
Hieruit volgt dat er een minimum moet zijn voor een bepaalde xd. Deze xd is omgekeerd
gecorreleerd met de a uit de geraden functie. Voor een grote a zal de verschilfunctie overal
8
stijgen en is xd = 0, maar als a klein genoeg is, kan er voor kleine x een daling zijn in de
verschilfunctie.
De techniek werkt dan als volgt. Er wordt begonnen met een duidelijke bovengrens
f(x) = axb. Dan wordt er gezocht naar een a > 0 waarvoor xd > 0. Als deze gevonden
wordt, is er geweten dat de b nog te groot is en wordt deze dus verminderd met een stap
δ. Dit wordt herhaald zolang er een a > 0 gevonden wordt waarvoor xd > 0. De laagste
b waarvoor er nog een a gevonden werd is dan de kleinste bovengrens gevonden door de
techniek. Merk op dat deze techniek niet dezelfde klasse aan functies kan inschatten als
de “Guess Ratio” techniek. Als er nog extra niet-constante factoren in de in te schatten
functie zitten, dan kunnen we niet zeggen dat xb het minimum is waar we naar zoeken.
De derde techniek heet de “Power” techniek en werkt als volgt. Op de datasets X
en Y wordt een logaritmische transformatie toegepast. De waarde b van f(x) = xb is
dan de helling van de lineaire regressie op de getransformeerde data. Deze techniek is
gebaseerd op het volgende idee. Als we de functie f(x) = axb nemen en we passen
dezelfde logaritmische transformatie toe (x′ = ln(x) en y′ = f(x)), dan bekomen we
y′ = bx′ + a. Dit is juist het lineaire model dat we inschatten met onze regressie.
De voorlaatste techniek is de “BoxCox” techniek. Deze techniek is een veralgemening
van de “Power” techniek. In plaats van enkel een logaritmische transformatie toe te
passen, is de transformatie die toegepast wordt geparameteriseerd door de parameter λ.
De transformatie is dan als volgt:
Y λ =
Y λ−1λY
λ−1 als λ > 0
Y ln(Y ) als λ = 0
Hierbij is Y het geometrisch gemiddelde van Y , oftewel Y = exp(gem(ln(Y ))). De “Box-
Cox” techniek past deze transformaties toe op een reeks bs. Deze bs (of toch tenminste
het interval waarin ze gezocht moeten worden) zijn ingegeven door de gebruiker. De λ
van de transformatie is dan 1/b. De transformatie waarbij de som van de gekwadrateerde
verschillen het kleinste is, is degene waarvan de b als antwoord wordt teruggegeven.
De laatste techniek (de “Difference” techniek) is gebaseerd op de gedeelde verschillen-
methode van Newton voor de interpolatie van veeltermen. Deze techniek berekent telkens
Y i = diff (Y i−1)/diff (X)i−1 en X i = diff (X i−1). Hierbij is diff een functie die de ver-
schillen tussen opeenvolgende waarden in het argument teruggeeft. Y 0 en X0 zijn gelijk
aan de experimentele data (dus respectievelijk Y en X). De eerste b waarbij alle waarden
9
van Y b gelijk zijn is de b van f(x) = xb.
De technieken voorgesteld in McGeoch et al. (2002) lijken optimaal voor ons om mee
verder te werken. Ze zijn onafhankelijk van de gebruikte programmeertaal. Het enige
probleem dat we kunnen zien is dat de technieken enkel werken voor polynomiale com-
plexiteitsfuncties, terwijl we natuurlijk zo veel mogelijk verschillende soorten complexi-
teitsfuncties willen ondersteunen.
10
Hoofdstuk 3
Werkwijze
Onze eigen implementatie voor het experimenteel inschatten van de computationele com-
plexiteit verliep in twee fases. We zijn begonnen met het nemen van metingen. Daarna
hebben we gewerkt aan het inschatten van de complexiteit op basis van deze metin-
gen. Verder hebben we ook gewerkt aan het automatisch kiezen van de parameters van
de metingen. De resulterende code van deze implementatie kan gevonden worden op
https://github.ugent.be/ecvpeteg/thesis-complexity/tree/master/code.
3.1 Metingen
Om van start te gaan met de opzet van deze thesis, zijn we begonnen met het automa-
tiseren van tijds- en geheugenmetingen van code. Hier was de opzet nog niet om het
systeem te creëren dat ons toelaat om automatisch verschillende testgevallen uit te voe-
ren. Het was vooral belangrijk om accurate metingen te kunnen uitvoeren. We hebben
hierbij gekozen om gebruik te maken van het bestaande Jupyter kernel framework (Kluy-
ver et al. (2016)). Jupyter kernels zijn de technologie achter de Jupyter Notebooks. We
hebben gekozen om gebruik te maken van deze Jupyter kernels om het gemakkelijk te
maken meerdere programmeertalen te ondersteunen. Het Jupyter framework biedt name-
lijk een uniforme interface om te interageren met deze kernels. Hierdoor is er zeer weinig
programmeertaal-specifieke functionaliteit nodig om een nieuwe programmeertaal te on-
dersteunen. Er bestaan ondertussen al 106 verschillende Jupyter kernels (Project Jupyter
(2018)) waardoor we met minimaal werk veel programmeertalen ondersteunen. Doordat
het Jupyter framework geschreven is in Python en er hiervoor dus ook het meeste support
11
is, hebben we gekozen om de implementatie van onze methodes ook in Python te doen.
We onderscheiden twee manieren om metingen uit te voeren. We kunnen dit doen
binnen de taal en buiten de taal.
3.1.1 Binnen de taal
Met het uitvoeren van metingen binnen de taal bedoelen we dat we bestaande voorzie-
ningen van de taal gebruiken om de metingen uit te voeren. Specifiek zijn dit bestaande
profilers (ook geheugen-profilers). Voor Python hebben we hiervoor een implementatie
gemaakt op basis van een aangepaste versie van de bestaande mem_profiler module.
Hierbij monitoren we het geheugengebruik per uitgevoerde lijn code. Om deze module te
activeren, kan ze ook als Jupyter kernel extensie ingeladen worden. Aangezien we ook de
huidige tijd opslaan op het moment dat we de geheugenmeting uitvoeren, zit hier impliciet
ook een tijdsmeting in. We doen deze metingen per uitgevoerde lijn code en ook wanneer
een functie binnengegaan en buitengegaan wordt. Dit alles doen we om zoveel mogelijk
informatie te verzamelen die we kunnen gebruiken om feedback terug te geven aan de
student.
3.1.2 Buiten de taal
Om metingen te doen buiten de taal, voeren we de metingen uit op het Jupyter ker-
nelproces. Tijdsmetingen zijn hierbij simpel. We slaan de huidige tijd op wanneer de
Jupyter kernel de relevante code begint uit te voeren en wanneer de Jupyter kernel rap-
porteert dat de code klaar is met uitvoeren. Dan nemen we het verschil tussen deze twee
tijdstippen. Om het geheugengebruik te meten, voeren we externe metingen uit op het
geheugengebruik van het Jupyter kernelproces. Tijdens de eerste 3 seconden van de uit-
voering nemen we elke 10 milliseconden een sample van het geheugengebruik, daarna elke
50 milliseconden. We hebben ervoor gekozen om minder samples te nemen na 3 seconden.
Enerzijds omdat we bij programma’s die langer uitvoeren op een hogere resolutie feedback
zullen geven over de meetresultaten. Anderzijds omdat de meeste programma’s waarbij
we deze feedback willen tonen toch binnen de 3 seconden zullen afgerond zijn (aangezien
deze feedback van de soort is die snel bij de student terecht moet komen).
12
3.1.3 Vergelijking
Het voordeel om de metingen binnen de taal te doen, is dat we veel informatie kunnen
gebruiken om feedback te geven aan de student. Bijvoorbeeld de uitgevoerde functies
samen met het geheugengebruik op één grafiek kunnen tonen, kunnen we met metingen
buiten de taal niet doen. Het is echter natuurlijk ook meteen duidelijk dat dit voor elke
ondersteunde programmeertaal een significante implementatiekost met zich meebrengt.
Door het gebruik van Jupyter kernels kunnen metingen buiten de taal op een generieke
manier uitgevoerd worden voor alle ondersteunde programmeertalen. Een ander verschil
is ook de kost in uitvoeringstijd. De code uitvoeren met een profiler is (toch zeker bij onze
Python-implementatie) significant trager dan zonder. Dit zorgt ervoor dat de feedback
minder snel tot bij de student geraakt. Als we met deze metingen de complexiteit willen
bepalen binnen een bepaalde tijdslimiet, dan zal deze tragere uitvoering zich ook mani-
festeren in minder uitgevoerde experimenten en dus ook minder betrouwbare resultaten.
Dit komt bij metingen buiten de taal uiteraard niet voor, aangezien het proces dat het
experiment uitvoert niet hoeft onderbroken te worden om de metingen te nemen.
3.1.4 Overhead
Bij beide manieren van werken moeten we voor elke uitvoering van het programma een
nieuwe Jupyter kernel opstarten om accurate geheugenresultaten te verkrijgen. Dit komt
voornamelijk door de runtime van de programmeertalen die we ondersteunen en doordat
de Jupyter kernel voorgaande resultaten bijhoudt (en de interactie tussen de twee). Dit
zorgt ervoor dat de gemeten allocaties van programma’s minder accuraat worden naar-
mate er meer testen uitgevoerd werden. Moderne runtimes zijn gebouwd om het aantal
allocaties zo klein mogelijk te houden zonder een grote overhead te creëren in het geallo-
ceerde geheugen. Daardoor worden de groottes van allocaties deels ook gebaseerd op het
huidige geheugengebruik van het programma. Daarnaast wordt niet al het geheugen dat
vrijgemaakt wordt door de garbage collector ook vrijgegeven naar het besturingssysteem
door de runtime van het programma. Al deze redenen zorgen ervoor dat de geheugenme-
tingen van latere testen binnen dezelfde Jupyter kernel minder accuraat zullen zijn. Het
opstarten van een nieuwe Jupyter kernel brengt wat overhead met zich mee. Voor Python
is deze overhead ongeveer 1 seconde.
13
3.2 Inschatten computationele complexiteit
Om de computationele complexiteit in te schatten hebben we twee implementaties ge-
maakt. We implementeerden eerst de vijf technieken van McGeoch et al. (2002) zoals
beschreven in sectie 2.2. Deze technieken bleken toch niet optimaal te zijn (zoals uit-
gelegd op het einde van 3.2.1) waardoor we een tweede implementatie gemaakt hebben
die ruwweg gebaseerd is op de ideëen uit McGeoch et al. (2002). Bij deze tweede im-
plementatie bespreken we ook hoe we het kiezen van parameters voor de experimenten
geïmplementeerd hebben.
3.2.1 Eerste implementatie
Als eerste implementatie om met de uitgevoerde metingen de computationele complexiteit
in te schatten, hebben we ons gebaseerd op de vijf technieken uitgewerkt in McGeoch et al.
(2002) zoals besproken in hoofdstuk 2. Deze implementatie gaf meteen beloftevolle resul-
taten. De technieken die voorgesteld worden werkten meteen vrij goed voor polynomiale
algoritmen. Ook uitbreidingen maken naar algoritmen met meer ingewikkelde complexi-
teitsfuncties ging redelijk goed. Zo is er bijvoorbeeld een uitbreiding gemaakt voor algo-
ritmen met een logaritmische factor (dus van de vorm xalog(x)). Het werd echter al vrij
snel duidelijk dat de methoden veralgemenen zodat we een arbitaire complexiteitsfunctie
konden inschatten niet voor de hand zou liggen. Voor één enkele complexiteitsfunctie is
de implementatie van een extra techniek niet te ingewikkeld. Aangezien er zeer veel moge-
lijke complexiteitsfuncties zijn, leek deze kost ons te hoog. Het was bij sommige van deze
geïmplementeerde technieken ook niet meteen duidelijk hoe we de betrouwbaarheid van de
bekomen resultaten moesten inschatten. Zo gaf de “Difference” techniek bijvoorbeeld geen
informatie over hoe betrouwbaar het resultaat was. We willen weten hoe betrouwbaar het
resultaat is aangezien deze technieken eventueel een verschillend resultaat kunnen geven
en we dus in de feedback ook willen kunnen aangeven welke methode meest waarschijnlijk
het juiste antwoord heeft. Ook het ondersteunen van meerdere complexiteitsparameters is
met deze methode vrij lastig. In McGeoch (1987) wordt voorgesteld om alle parameters
behalve 1 vast te zetten en dan de parameter die niet vaststaat in te schatten tot alle
parameters ingeschat zijn. Dit zou in de meeste gevallen inderdaad werken, maar vraagt
veel extra uitvoeringen van de code. Er zijn ook enkele gevallen waarin dit niet meteen zal
14
werken: het is niet snel in te zien hoe we hiermee het onderscheid kunnen maken tussen
een complexiteit van O(m+ n) en een complexiteit van O(m ∗ n).
3.2.2 Tweede implementatie
Om tegemoet te komen aan de gestelde problemen, zijn we overgeschakeld naar een an-
dere methode om de complexiteit in te schatten. Hierbij vertrekken we in plaats van
een generieke voorgedefinieerde lijst van complexiteitsfuncties van een lijst van complexi-
teitsfuncties die per oefening ingesteld wordt. We verwachten van de oefening om deze
complexiteitsfuncties toe te passen op de complexiteitsparameters. We hebben hiervoor
een Python klasse Exercise geschreven waar een oefening van kan overerven. Deze klasse
bevat een aantal abstracte methoden die door een specifieke oefening geïmplementeerd
moeten worden:
• name()
Deze methode moet de naam van de oefening teruggeven. Dit wordt enkel gebruikt
om de ruwe data georganiseerd te kunnen opslaan.
• get_function()
Deze methode moet de code teruggeven die uitgevoerd moet worden om de code
ingediend door de student te testen. De code moet teruggegeven worden onder
de vorm van een lijst van format strings. Hierbij moeten de format strings de
resultaten van de get_test methode interpoleren. Enkel op de uitvoering van het
laatste element van de lijst worden er metingen genomen. Dit is het geval om
beperkingen van programmeertalen weg te werken. Zo zijn we bijvoorbeeld bij Java
op de limiet op de grootte van de resulterende bytecode per methode gestoten. Om
dit te omzeilen, hebben we de testgevallen uitgeschreven naar een bestand dat dan
op voorhand ingelezen wordt om dynamisch een array te vullen. Dit inlezen willen
we echter niet bij het uitvoeren van de code zelf rekenen.
• initial_parameters()
Deze methode moet een tuple of een lijst teruggeven van initiële inputgroottes. De
methode dient om in de uitvoering van onze methode een beeld te hebben waar we
ongeveer moeten beginnen. Het kan namelijk zijn dat voor een bepaald probleem
al voor een inputgrootte van 100 het probleem veel tijd zal vragen, terwijl voor
15
andere problemen een inputgrootte van 100 amper de moeite waard is om uit te
voeren. Aangezien het zeer moeilijk is (en ook buiten de scope van deze thesis)
om eerst een inschatting te maken van waar we ongeveer moeten beginnen en wat
de stapgrootte ongeveer moet zijn, vragen we dit aan de oefening. De stapgrootte
wordt ook vastgesteld op basis van de initiële parameters.
• get_test(parameters)
Deze methode moet, gegeven een tuple of een lijst van parameters, een tuple of lijst
van argumenten teruggeven. Deze argumenten zullen geïnterpoleerd worden in de
code die door de get_function methode wordt teruggegeven.
• symbolic_time_complexity()
Deze methode moet een tuple of een lijst van strings teruggeven waarin de mogelijke
tijdscomplexiteitsfuncties symbolisch voorgesteld worden. Deze methode wordt en-
kel gebruikt om achteraf een overzicht te kunnen geven van de complexiteitsfuncties
en de waarschijnlijkheid dat een oplossing voor de oefening die complexiteit heeft.
• time_complexity(parameters)
Deze methode moet een tuple of een lijst teruggeven van de resultaten van de com-
plexiteitsfuncties als de gegeven inputgroottes als parameters gebruikt worden. Dus
als bijvoorbeeld de complexiteitsfuncties m + n, m ∗ n en m ∗m zijn en de input-
groottes (5, 10) zijn, moet de methode (15, 50, 25) teruggeven.
• symbolic_memory_complexity()
Deze methode is identiek aan de symbolic_time_complexity()methode, maar dan
voor geheugencomplexiteitsfuncties in plaats van tijdscomplexiteitsfuncties.
• memory_complexity(parameters)
Deze methode is identiek aan de time_complexity(parameters) methode, maar
dan voor geheugencomplexiteitsfuncties in plaats van tijdscomplexiteitsfuncties.
• language()
Deze methode moet de programmeertaal teruggeven waarin de oefening gemaakt
moet worden. Deze taal zal gebruikt worden om de juiste Jupyter kernel op te
starten. Momenteel moet het resultaat van deze methode gelijk zijn aan de naam
16
van de relevante Jupyter kernel. Dit komt echter zo goed als altijd overeen met de
naam van de programmeertaal.
• get_submissions()
Deze methode moet een dictionary teruggeven die de naam of de id van de student
afbeeldt op de code die de student heeft ingediend. Om de implementatie voor
oefeningen op Dodona te vergemakkelijken, is er een klasse DodonaExercise waarbij
de methode submissions_url() moet geïmplementeerd worden in plaats van de
methode get_submissions(), die dan uiteraard de URL van de oplossingen van
de oefening op Dodona moet teruggeven. Bij de DodonaExercise klasse is er ook
nog een methode preload_code() die dient om stukken code terug te geven die
noodzakelijk zijn voor de successvolle compilatie van een oplossing, maar er geen
deel van uitmaken (bijvoorbeeld de definitie van een interface waaraan studenten
moesten voldoen in Java).
Een klasse die al deze methoden implementeert, kan gebruikt worden om de complexi-
teit van de oplossingen in te schatten. Uiteindelijk zal er voor alle mogelijke complexi-
teitsfuncties een betrouwbaarheid als output gegeven worden. Het bepalen van deze
betrouwbaarheid is niet complex. Deze is gelijk aan de R2 statistiek (de correlatie-
coëfficient) van de lineaire regressie tussen de meetresultaten en de resultaten van de
time_complexity en memory_complexity methodes. Een voorbeeld van een implemen-
tatie voor een Python oefening kan gevonden worden op https://github.ugent.be/
ecvpeteg/thesis-complexity/blob/master/code/global_alignment_exercise.py. Een
voorbeeld van een Java oefening kan gevonden worden op https://github.ugent.be/
ecvpeteg/thesis-complexity/blob/master/code/stable_marriage_exercise.py.
Kiezen van parameters
Het belangrijkste element van de implementatie dat nog besproken moet worden, is hoe we
de parameters kiezen. Zoals gezegd in de bespreking van de Exercise klasse beginnen we
met het resultaat van de initial_parameters methode. Hiervoor wordt dus al meteen
een eerste meting gedaan. Daarna wordt het volgende proces herhaald tot er geen volgende
argumenten worden teruggegeven of tot de tijdslimiet overschreden is.
1. Verzamel alle complexiteitsfuncties waarvoor de betrouwbaarheid hoog genoeg is.
17
Deze betrouwbaarheidsdrempel kan ingesteld worden (met als standaardwaarde 0.8).
2. Voeg parameters toe aan een verzameling tot alle complexiteitsfuncties niet paars-
gewijs correleren. Hierbij wordt de lineaire regressie, die berekend werd om de
betrouwbaarheid te bepalen, gebruikt om tijdelijk nieuwe elementen aan de dataset
van de metingen toe te voegen. We zeggen dat twee complexiteitsfuncties niet cor-
releren wanneer de R2 statistiek van de lineaire regressie tussen de twee kleiner is
dan de betrouwbaarheidsdrempel.
De tijds- en geheugencomplexiteitsfuncties worden hier uiteraard apart behandeld.
3. Verwijder de parameters waarvoor de verwachte uitvoeringstijd te groot is. Hierbij
nemen we de verwachte uitvoeringstijd als de grootste verwachte uitvoeringstijd van
alle tijdscomplexiteitfuncties waar we nog rekening mee houden.
4. Verwijder de parameters die niet noodzakelijk zijn om de functies niet paarsgewijs te
laten correleren. Dit kan bijvoorbeeld voorkomen wanneer er voor O(n) en O(n3) te
onderscheiden een parameter 1000 is toegevoegd. Als er later ook nog een parameter
1500 wordt toegevoegd om O(n) en O(n2) te onderscheiden, dan is de parameter
1000 niet meer nodig. Aangezien we in de vorige stap misschien parameters verwij-
derd hebben waardoor er sommige complexiteitsfuncties wel paarsgewijs correleren,
zorgen we er hier voor dat het aantal paarsgewijs correlerende complexiteitsfuncties
niet stijgt. Dit proces is op dit moment nog vrij naïef uitgewerkt: de parameters
worden niet in een bepaalde volgorde overlopen of in meerdere volgordes getest, dus
het kan dat we een parameter verwijderen die er voor zorgt dat we meerdere andere
parameters niet kunnen verwijderen.
5. Geef de parameters terug. Dit kan op meerdere manieren gebeuren, afhankelijk
van hoe het programma ingesteld is. Als er bijvoorbeeld nog maar één (of geen)
complexiteitsfunctie correleert in de beide categorieën, dan zal het beschreven pro-
ces geen parameters bepaald hebben. Het is mogelijk om in te stellen dat er altijd
parameters teruggegeven worden, waarbij er dan teruggevallen wordt op het maxi-
mum van de parameters dat tot nu toe werd uitgevoerd plus de stapgrootte. Voor
meerdere parameters wordt dit maximum apart bepaald. Met andere woorden, de
verschillende maxima kunnen van verschillende uitvoeringen komen.
18
Daarnaast kan er ook gekozen worden hoeveel keer elke set parameters teruggegeven
wordt. Dit kan helpen om de variantie op de resultaten te verminderen (zoals
beschreven in sectie 2.2).
Verminderen van overhead
Om het probleem van de overhead dat in sectie 3.1.4 werd vermeld op te lossen, hebben
we ervoor gekozen om een pool van kernels op te starten bij het begin van onze analyse.
Op die manier staat er al een kernel klaar op het ogenblik dat een meting moet uitgevoerd
worden. Van zodra er een kernel uit deze pool gebruikt wordt, wordt er een nieuwe Jupyter
kernel opgestart en aan deze pool toegevoegd.
19
Hoofdstuk 4
Resultaten
Om de effectiviteit van onze implementatie te testen hebben we enkele experimenten
gedaan. We beginnen met een overzicht van de verschillende grafieken die we met de
metingen kunnen maken. Daarna bespreken we enkele programma’s waarvan we de com-
plexiteit inschatten. Uiteindelijk is er ook nog een bespreking van een bevraging die we bij
studenten hebben uitgevoerd om het nut van de grafieken in te schatten en een vergelij-
king te maken van manueel ingeschatte complexiteit tegenover de complexiteit ingeschat
door onze methode.
4.1 Geheugenmetingen
Om de verschillende manieren te tonen waarop de geheugenmetingen gebruikt kunnen
worden, hebben we een simpel voorbeeldprogramma geschreven met duidelijke verschillen
in geheugengebruik doorheen de tijd. Dit voorbeeldprogramma kan gevonden worden in
Listing 4.1.
Het belangrijkste element van het programma is de functie my_func2. Deze alloceert
twee lijsten van verschillende grootte. De grootste (b) wordt na de allocatie meteen
expliciet terug vrijgegeven. De kleinere lijst (a) wordt teruggegeven. Grafieken van de
metingen buiten de taal en binnen de taal kunnen respectievelijk gevonden worden in
Figuur 4.1 en Figuur 4.2.
Het is meteen duidelijk dat voor de metingen binnen de taal veel minder meetpunten
genomen moeten worden om een gelijkaardige figuur te krijgen. Buiten de taal moeten
we op vaste intervallen metingen uitvoeren, terwijl we binnen de taal enkel metingen
20
1 import time
2
3 def my_func2 ( ) :
4 a = [ 1 ] ∗ (10 ∗∗ 6)
5 b = [ 2 ] ∗ (2 ∗ 10 ∗∗ 7)
6 del b
7 return a
8
9 def my_func ( ) :
10 my_func2 ( )
11 time . s l e e p ( 0 . 1 )
12 c = my_func2 ( )
13 time . s l e e p ( 0 . 1 )
14 my_func2 ( )
15 time . s l e e p ( 0 . 1 )
16 del c
17 time . s l e e p ( 0 . 1 )
18 my_func2 ( )
19 time . s l e e p ( 0 . 1 )
Listing 4.1: Voorbeeldprogramma voor de grafieken van de geheugenmetingen. De oproep
naar de functie my_func staat niet in de code, aangezien die door onze metingssoftware
dynamisch ingevoegd wordt.
21
Figuur 4.1: Geheugenmetingen buiten de taal van het voorbeeldprogramma in Listing
4.1.
Figuur 4.2: Geheugenmetingen binnen de taal van het voorbeeldprogramma in Listing
4.1. De rode lijnen op de grafiek tonen het verloop van de functies binnen het programma
(de bovenste lijn is de tijd waarin de my_func methode wordt opgeroepen, de andere lijnen
zijn de oproepen van my_func2).
22
uitvoeren tussen het uitvoeren van twee statements. We kunnen echter ook zien dat de
metingen binnen de taal zelfs voor dit kleine voorbeeld al een duidelijke overhead met
zich meebrengen. De metingen buiten de taal zijn klaar na ongeveer 1.1 seconden, terwijl
er voor de metingen binnen de taal 1.3 seconden nodig zijn.
Een ander resultaat dat in deze metingen kan gezien worden is de invloed van de
garbage collector. In beide metingen kunnen we zien dat na de derde uitvoer van my_func2
de kleinere lijst onnodig in het geheugen blijft. De lijst wordt pas vrijgegeven bij het
expliciete verwijderen van de c variabele. Ook na de vierde uitvoer van my_func2 is dit
het geval.
We kunnen ook zien dat we bij de metingen binnen de taal een koppeling kunnen
maken tussen de functie-aanroepen en de geheugenmetingen doorheen de tijd.
4.2 Inschatten complexiteit
We bespreken eerst de resultaten die we behaald hadden met de implementatie van de
vijf technieken uit McGeoch et al. (2002). Daarna volgt een uitgebreidere bespreking van
onze eigen techniek voor het inschatten van de complexiteit.
4.2.1 Eerste implementatie
Om de technieken uit McGeoch et al. (2002) te testen, maakten we gebruik van simula-
tiefuncties. Deze geven een fictieve tijdsmeting terug. Een functie waar we meetpunten
uit haalden was bijvoorbeeld 10n2 + 5n+ 100 + random.randint(0, 10). Drie van de vijf
methoden vonden hiervoor een tijdscomplexiteit van O(n2). De “Guess Ratio” techniek
schat een ondergrens voor de complexiteit van O(n1.9869) met een betrouwbaarheid van
0.998. De “Guess Difference” techniek schat een bovengrens van O(n2.6499). Deze tech-
niek geeft geen informatie terug over de betrouwbaarheid, dus we rekenen dit antwoord
fout. De “Power” techniek schat een complexiteit van O(n2) met een betrouwbaarheid van
0.998. De “Box-Cox” techniek geeft een 95% betrouwbaarheidsinterval terug. Het interval
dat teruggeven wordt is echter [2.691, 2.776]. Dit beschouwen we dus ook als een fout
antwoord. De “Difference” techniek schat een complexiteit van O(n2) in. Deze techniek
geeft ook geen info terug over de betrouwbaarheid van dit resultaat, maar het is wel juist.
De totale gesimuleerde uitvoeringstijd was tussen de 0.5 en 2 minuten. Deze werd bepaald
23
door de resultaten van de functie bij elkaar op te tellen en als milliseconden te beschou-
wen. De argumenten voor de simulatiefunctie werden nog manueel gekozen. Aangezien
we ons al snel realiseerden dat deze manier van werken niet zeer uitbreidbaar was (zoals
uitgelegd in sectie 3.2.1), hebben we verder geen andere experimenten uitgevoerd.
4.2.2 Tweede implementatie
In het tweede semester hebben we voor een oefening uit het vak Computationele Bio-
logie gebruik gemaakt van de techniek die we al hadden geïmplementeerd om tijds- en
geheugenmetingen te doen. Hiermee wilden we nagaan hoe nuttig deze metingen zijn
voor studenten om de complexiteit van code in te schatten. De oefening die de studenten
moesten oplossen was het implementeren van een algoritme om tussen twee strings met
lengtem en n (bijvoorbeeld DNA sequenties) een optimale alignment te vinden met lineair
geheugengebruik. Een naïeve implementatie die gebruikt maakt van dynamisch program-
meren heeft normaal O(m×n) als geheugencomplexiteit, maar in Hirschberg (1975) wordt
een algoritme beschreven met O(min(m,n)) geheugencomplexiteit. De resultaten van de
bevraging bij de studenten over het nut van de informatie die ze uit onze metingen kregen
zullen in sectie 4.3 besproken worden. Daarnaast hebben we deze oefening ook gebruikt
om aan de hand van eigen oplossingen te kijken hoe goed de complexiteit werd inge-
schat. In totaal hebben we vier oplossingen getest met eenvoudige implementaties. Deze
implementaties hadden telkens tijdscomplexiteit O(m × n). De geheugencomplexiteiten
van de vier implementaties (en waarvoor getest werd) waren O(min(m,n)), O(m), O(n)
en O(m × n). Voor de tijdscomplexiteit waren de complexiteitsfuncties waarvoor getest
werd de volgende: O(m × n), O(m) en O(n). Hierbij waren we vooral geïnteresseerd in
de geheugencomplexiteit. (De O(m) en O(n) tijdscomplexiteitsfuncties zouden vrij snel
geëlimineerd moeten worden.) Voor dit eerste experiment was de totale uitvoeringstijd
voor het experimenteel testen maximaal 10 minuten, werden de argumenten drie keer uit-
gevoerd, was de betrouwbaarheidslimiet gelijk aan 0.8 en stopten we niet eerder tot er in
beide categoriën nog maar één complexiteitsfunctie overschoot met een betrouwbaarheid
hoger dan de limiet (zoals uitgelegd in het “Kiezen van parameters” deel van sectie 3.2.2).
De gebruikte inputgroottes voor de verschillende programma’s kunnen gevonden worden
in Tabel 4.1.
De evoluties van de correlatiecoëfficienten van de lineaire regressies tussen de tijdscom-
24
Test Programma 1 Programma 2 Programma 3 Programma 4
1 500, 500 500, 500 500, 500 500, 500
2 500, 1500 500, 1500 500, 1500 500, 1500
3 1500, 500 1500, 500 1500, 500 1500, 500
4 500, 1500 500, 1500 500, 1500 500, 1500
5 1500, 500 1500, 500 1500, 500 1500, 500
6 500, 1500 500, 1500 500, 1500 500, 1500
7 1500, 500 2500, 1500 1500, 500 1500, 500
8 3500, 3500 1500, 5000 1500, 4000 4500, 1500
9 5000, 1500 2500, 1500 4000, 1500 4500, 1500
10 3500, 3500 1500, 5000 1500, 4000 4500, 1500
11 5000, 1500 2500, 1500 4000, 1500 4500, 3500
12 3500, 3500 1500, 5000 1500, 4000 7000, 1500
13 5000, 1500 4500, 7000 4000, 1500 4500, 15000
14 14500, 3500 2500, 13000 4000, 17000 4500, 4500
15 2500, 10500 11500, 4000 16000, 1500
16 2500, 18500 17000, 4000 4500, 3500
17 13500, 5000 7000, 1500
18 4500, 15000
Tabel 4.1: Gebruikte inputgroottes bij het uitvoeren van de testen voor het eerste experi-
ment. De eerste waarde is telkens m (de lengte van de eerste string) en de tweede waarde
is telkens n (de lengte van de tweede string). Alle programma’s hebben tijdscomplexiteit
O(m× n). Het eerste programma heeft geheugencomplexiteit O(min(m,n)). Het tweede
programma heeft geheugencomplexiteit O(m). Het derde programma heeft geheugencom-
plexiteit O(n). Het vierde programma heeft geheugencomplexiteit O(m × n). Niet alle
inputgroottes komen drie keer voor in de lijst. De eerste inputgroottes worden niet her-
haald omdat ze niet door de functie om de parameters te kiezen teruggegeven worden. De
paar laatste elementen van de lijst kunnen ook minder dan drie keer voorkomen omdat
de uitvoering afgebroken werdt door de tijdslimiet.
25
Figuur 4.3: Evolutie van de correlatiecoëfficienten voor de tijdscomplexiteitsfuncties na
elke uitgevoerde test van het eerste experiment (algoritme van Hirschberg, variabele ge-
heugencomplexiteit). Elk programma heeft duidelijk tijdscomplexiteit O(m× n).
plexiteitsfuncties toegepast op de inputgroottes en de meetresultaten na elke uitgevoerde
test voor de vier programma’s zijn te vinden in Figuur 4.3. Ter referentie zijn ook de
uitvoeringstijden te zien in Figuur 4.4. We kunnen zien dat voor elk programma duidelijk
O(m× n) als tijdscomplexiteit gevonden wordt. De tijdscomplexiteitsfuncties die lineair
zijn in één van de parameters, worden over het algemeen snel uitgesloten.
Voor de geheugencomplexiteitsfuncties zijn de evoluties van de correlatiecoëfficienten
te vinden in Figuur 4.5. Ter referentie zijn ook weer de resultaten van de metingen
te vinden in Figuur 4.6. Voor het geheugengebruik kunnen we zien dat de methode
het minder goed doet. Voor het tweede, derde en vierde programma vindt de methode
telkens het juiste resultaat. Bij het eerste programma is het resultaat ook juist, maar
een stuk minder duidelijk. De correlatiecoëfficient ligt een stuk lager, en dit voor alle
complexiteitsfuncties. Vermoedelijk is er bij het eerste programma meer variantie op het
geheugengebruik en zou een langere uitvoer een betrouwbaarder resultaat opleveren.
Na het uitvoeren van dit experiment met een vaste tijdslimiet hebben we een expe-
26
Figuur 4.4: Gemeten uitvoeringstijd voor elke uitgevoerde test van het eerste experiment.
De gebruikte parameters kunnen gevonden worden in tabel 4.1.
27
Figuur 4.5: Evolutie van de correlatiecoëfficienten voor de geheugenscomplexiteitsfuncties
na elke uitgevoerde test van het eerste experiment.
28
Figuur 4.6: Gemeten geheugengebruik voor elke uitgevoerde test van het eerste experi-
ment. De gebruikte parameters kunnen gevonden worden in tabel 4.1.
29
riment uitgevoerd om de impact van de maximale uitvoeringstijd op de evolutie van de
correlatiecoëfficienten in te schatten. Hierbij werden de vier programma’s 5 minuten, 10
minuten, 20 minuten en zonder tijdslimiet uitgevoerd. Hierbij stoppen we niet als er maar
één complexiteitsfunctie overblijft waarvoor de correlatiecoëfficient boven de ingestelde li-
miet ligt, behalve in het geval zonder tijdslimiet (anders hebben we geen stopconditie).
De resultaten voor de tijdscomplexiteitsfuncties kunnen respectievelijk gevonden worden
op Figuur 4.7, Figuur 4.9, Figuur 4.11 en Figuur 4.13. De resultaten voor de geheugen-
complexiteitsfuncties kunnen respectievelijk gevonden worden op Figuur 4.8, Figuur 4.10,
Figuur 4.12 en Figuur 4.14.
We kunnen zien dat bij dit experiment de extra tijd voor het inschatten van de tijds-
complexiteit niet veel verschil maakt. De methode vindt altijd snel de (juiste) tijdscom-
plexiteit O(m × n). Voor het inschatten van de geheugencomplexiteit kunnen we zien
dat deze extra tijd wel helpt. Bij 5 en 10 minuten (Figuur 4.8 en Figuur 4.10) toont de
schatting voor enkele programma’s een vreemd gedrag. Bij 20 minuten (Figuur 4.12) zien
we dat voor alle programma’s de juiste complexiteit zich systematisch bovenaan bevindt.
Ook voor het experiment zonder tijdslimiet (Figuur 4.14) is dit het geval. We zien wel
nog steeds dat het lastiger is om de O(min(m,n)) complexiteitsfunctie te onderscheiden
dan de andere complexiteitsfuncties.
Iets anders dat we kunnen opmerken is dat de figuren van de experimenten met meer
tijd geen uitbreiding zijn op de figuren van de experimenten met minder tijd. Dit komt
door de variantie op de meetresultaten. Door deze variantie worden er ander inputgroottes
gekozen en kunnen de berekende correlatiecoëfficienten verschillen.
Traag stijgende functies
Bij een volgend experiment hebben we bekeken hoe goed onze techniek functies kan in-
schatten die zeer traag stijgen. Hiervoor hebben we getest of onze techniek voor binair
zoeken in een gesorteerde lijst een O(log(n)) tijdscomplexiteit kan vinden. Op Figuur
4.15 kunnen we zien dat de methode er niet zo goed in slaagt om O(log(n)) als tijdscom-
plexiteitsfunctie te vinden. Als we kijken naar Figuur 4.16 dan kunnen we zien dat de
tijdsmetingen zich ook nagenoeg lineair gedragen. De implementatie is nochtans duidelijk
logaritmisch (te zien op Listing 4.2). Het probleem bevindt zich ook niet in de gekozen
inputgroottes. De initiële inputgrootte is 50000. Na deze meting kiest onze techniek als
30
Figuur 4.7: Evolutie van de correlatiecoëfficienten voor de tijdscomplexiteitsfuncties na
elke uitgevoerde test van het geval van het tweede experiment met 5 minuten als tijdsli-
miet. Elk programma heeft duidelijk tijdscomplexiteit O(m× n).
31
Figuur 4.8: Evolutie van de correlatiecoëfficienten voor de geheugenscomplexiteitsfuncties
na elke uitgevoerde test van het geval van het tweede experiment met 5 minuten als
tijdslimiet.
32
Figuur 4.9: Evolutie van de correlatiecoëfficienten voor de tijdscomplexiteitsfuncties na
elke uitgevoerde test van het geval van het tweede experiment met 10 minuten als tijds-
limiet. Elk programma heeft duidelijk tijdscomplexiteit O(m× n).
33
Figuur 4.10: Evolutie van de correlatiecoëfficienten voor de geheugenscomplexiteitsfunc-
ties na elke uitgevoerde test van het geval van het tweede experiment met 10 minuten als
tijdslimiet.
34
Figuur 4.11: Evolutie van de correlatiecoëfficienten voor de tijdscomplexiteitsfuncties
na elke uitgevoerde test van het geval van het tweede experiment met 20 minuten als
tijdslimiet. Elk programma heeft duidelijk tijdscomplexiteit O(m× n).
35
Figuur 4.12: Evolutie van de correlatiecoëfficienten voor de geheugenscomplexiteitsfunc-
ties na elke uitgevoerde test van het geval van het tweede experiment met 20 minuten als
tijdslimiet.
36
Figuur 4.13: Evolutie van de correlatiecoëfficienten voor de tijdscomplexiteitsfuncties na
elke uitgevoerde test van het geval van het tweede experiment waar er geen tijdslimiet
was ingesteld. Elk programma heeft duidelijk tijdscomplexiteit O(m× n).
37
Figuur 4.14: Evolutie van de correlatiecoëfficienten voor de geheugenscomplexiteitsfunc-
ties na elke uitgevoerde test van het geval van het tweede experiment waar er geen tijds-
limiet was ingesteld.
38
Figuur 4.15: Evolutie van de correlatiecoëfficienten voor de tijdscomplexiteitsfuncties van
het binair zoeken experiment.
volgende inputgrootte 150000 omdat er vanaf dan een merkbaar verschil zit in de ver-
wachte resultaten. Dit verschil zit ook in deze resultaten en de twee complexiteitsfuncties
correleren niet meer met elkaar, waardoor er vanaf dan enkel met de stapgrootte wordt
vermeerderd. We vermoeden dat deze lineairiteit te wijten is aan een overhead van de
Jupyter kernel. We zijn er niet in geslaagd deze overhead weg te werken. De argumenten
worden in een aparte uitvoering waarop geen metingen uitgevoerd in variabelen ingeladen,
dus de constructie van de lijst is niet het probleem. Het eigenlijke probleem hebben we
niet kunnen vinden.
Java programma’s
Om de effectiveit van onze techniek te testen op Java programma’s hebben we als laatste
experiment een oefening uit het vak Algoritmen en Datastructeren gebruikt. De oefening
bestond uit het implementeren van het Stable Marriage Problem. In Gale and Shapley
(1962) werd dit probleem uitgelegd en werd meteen ook een algoritme gegeven voor een
optimale oplossing die in het slechtste geval in kwadratische tijd gevonden kan worden.
39
Figuur 4.16: Tijdsmetingen voor het binair zoeken experiment.
1 def zoeken ( element , l i j s t ) :
2 begin = 0
3 e inde = len ( l i j s t ) − 1
4 while begin <= einde :
5 m = ( begin + einde ) // 2
6 i f l i j s t [m] == element :
7 return m
8 e l i f l i j s t [m] > element :
9 e inde = m − 1
10 else :
11 begin = m + 1
12 return −1
Listing 4.2: Implementatie van binair zoeken waarvan de tijdscomplexiteit gezocht wordt.
40
Figuur 4.17: Evolutie van de correlatiecoëfficienten voor de tijdscomplexiteitsfuncties van
het Stable Marriage Problem experiment.
Hierbij zijn we meteen ook een interessant probleem tegengekomen. Onze eerste imple-
mentatie om testgevallen te genereren deed dit op een volledig willekeurige manier. Dit
resulteerde echter in slechte resultaten van onze methode. Wanneer we een tweede imple-
mentatie maakten om testgevallen te genereren hebben we ons gebaseerd op een methode
om het slechtste geval te construeren zoals voorgesteld in Kapur and Krishnamoorthy
(1985). Na deze aanpassing vindt onze methode voor een Java implementatie van het
Gale-Shapley algoritme een duidelijke O(n2) tijdscomplexiteit. In Figuur 4.17 kan de
evolutie van de correlatiecoëfficienten van de tijdscomplexiteitsfuncties gevonden worden
voor een oplossing van een student voor deze opdracht.
4.3 Feedback van studenten
Zoals vermeld in sectie 4.2.2 hebben we ook een bevraging gedaan onder de studenten die
het opleidingsonderdeel Computationele Biologie gevolgd hebben in academiejaar 2017–
2018. De code van de studenten werd 5 minuten uitgevoerd en op die tijd draaiden we
41
zoveel mogelijk testen. De parameters van de testen werden nog niet bepaald op de manier
zoals beschreven in 3.2.2. Ze werden gekozen door te beginnen bij 500 en er telkens 500
bij op te tellen. De parameters m en n waren dus ook altijd gelijk. Van deze testen werd
de uitvoeringstijd en het maximum geheugengebruik bijgehouden. Elke student kreeg van
drie andere studenten telkens de code samen met 4 grafieken. De eerste twee grafieken
waren voor de gemeten tijd. Hierbij werden voor een grafiek de resultaten van de andere
student alleen op een grafiek gezet. De tweede grafiek toonde de resultaten van de andere
student samen met de resultaten van alle andere studenten. De volgende twee grafieken
waren hetzelfde maar dan voor geheugengebruik in plaats van tijd. Een voorbeeld van
de grafieken die aan de studenten gegeven werden kan gezien worden in Figuur 4.18. De
drie outliers op deze grafieken verklaren we door het feit dat er op het moment dat dit
experiment uitgevoerd werd, we nog niet keken naar fouten tijdens de uitvoering van de
code. De code die uitgevoerd werd kwam immers van correcte oplossingen van Dodona
kwam. Een latere heruitvoering van het experiment, wanneer fouten binnen de uitvoering
wel opgevangen werden, maakte dan echter duidelijk dat deze 3 indieningen toch nog
fouten bevatten zodra de inputgrootte een stuk groter werd dan de inputgrootte van de
testen die op Dodona werden uitgevoerd.
Aan de studenten werd dan gevraagd een inschatting te geven van de tijds- en geheu-
gencomplexiteit van de code. Daarnaast waren er ook 3 vragen om in te schatten hoe de
studenten de gegeven grafieken gebruikt hadden. Deze vragen waren als volgt:
• Hoe heb je de gegeven grafieken gebruikt?
• Wat vond je nuttig aan de gegeven grafieken?
• Wat zou je verbeteren aan de gegeven grafieken?
Bij de eerste vraag kwamen 5 verschillende antwoorden naar voor. In Tabel 4.2 kunnen
deze gevonden worden, samen met het aantal studenten die dat antwoord gaven. We
kunnen zien dat er veel studenten de grafieken gebruikt hebben om de complexiteit in te
schatten of om de snelheid en het geheugengebruik te vergelijken.
De tweede vraag had een duidelijk dominerend antwoord, namelijk dat het handig
was om de oplossing te positioneren tegenover de andere studenten. Voor de volledigheid
kunnen de verschillende antwoorden en het aantal keer dat ze gegeven werden, gevonden
worden in Tabel 4.3.
42
Figuur 4.18: De linkse grafieken tonen de metingen van één indiening. De rechtse grafieken
tonen deze metingen in vergelijking met de rest van de studenten. De outliers op de
rechtse grafieken komen door een gebrek aan afhandeling van fouten binnen de code van
de studenten.
Antwoord Aantal
Prestaties van het programma bekijken. 8
Vergelijken van prestaties met andere studenten. 19
Het bepalen van de complexiteit. 18
Kijken of het programma in lineaire ruimte geïmplementeerd is. 2
Checken van manueel bepaalde complexiteit. 2
Tabel 4.2: Antwoorden van studenten op de vraag “Hoe heb je de gegeven grafieken
gebruikt?”
43
Antwoord Aantal
Handig om de oplossing te positioneren tegenover de andere studenten. 33
Nuttig bij het vormen van een score. 3
Individuele tijdsgrafiek gaf duidelijk complexiteit weer. 2
Zelf geen metingen te moeten uitvoeren. 2
Extra vertrouwen in de zelf bekomen meetresultaten. 2
De grafieken. 5
Tabel 4.3: Antwoorden van studenten op de vraag “Wat vond je nuttig aan de gegeven
grafieken?”
Bij de derde vraag waren er veel verschillende antwoorden. Deze kunnen gevonden
worden in Tabel 4.4. Het meest voorkomende antwoord had betrekking op de uitschie-
ters. De 3 inzendingen waar fouten in zaten konden zeer veel testgevallen afwerken omdat
ze vroeg in hun uitvoering afgebroken werden. Dit maakte de grafieken een stuk min-
der leesbaar. Dit vonden veel studenten duidelijk niet handig bij het gebruiken van de
grafieken.
Naast de vragen over de grafieken, werd er ook van elke student gevraagd om een
inschatting te maken van de tijds- en geheugencomplexiteit van de stukken code die ze
moesten beoordelen. Hierdoor hadden we na de verwerking van de ingediende complexitei-
ten ongeveer 3 inschattingen van de complexiteit per oplossing. (Het aantal inschattingen
is niet exact 3 omdat sommige studenten geen antwoord hadden gegeven of niet geant-
woord hadden met een complexiteitsfunctie.)
Van de tijdscomplexiteitsfuncties kwam voor 46 van de 53 oplossingen de complexi-
teit ingeschat door onze techniek en de complexiteit ingeschat door de studenten volledig
overeen. Van de zeven oplossingen waar de manuele inschattingen en de automatische
inschatting niet volledig overeen kwam waren er zes waar er maar één manuele inschat-
ting niet overeenkwam. Deze zijn duidelijke fouten van de student (bijvoorbeeld een
inschatting van O(m+ n) voor een algoritme dat minstens O(m× n) is). Bij de zevende
oplossing waar de manuele inschattingen en de automatische inschattingen niet overeen-
kwamen is het minder duidelijk: er zijn twee inschattingen waarvan er één duidelijk fout
is (O(2m∗n)) en de andere een inschatting van O(m×n) is (van deze student hadden maar
twee andere studenten de code gekregen). De code van de student is zeer simpel; deze
44
Antwoord Aantal
Grafieken/vergelijkingen toevoegen met de eigen code. 2
Layout van de PDFs verbeteren. 3
Interactief de configuratie van de grafieken kunnen aanpassen. 1
Resultaten consistenter maken. 1
Langere tijdsduur. 3
Rekening houden met uitschieters bij de grafieken. 5
Lijnen in plaats van punten voor de andere studenten. 1
Ook informatie over de correctheid van de code toevoegen. 1
Ook de data geven waarop de grafieken gebaseerd zijn. 2
Meer uitleg geven bij de grafieken. 1
Een oplossing vinden voor de grijze wolk van resultaten. 2
Een onderscheid maken tussen de n beste resultaten en de rest. 1
Voor de geheugencomplexiteit 2 grafieken i.p.v. min(m,n). 2
O-notatie van de complexiteit meeleveren. 1
Tabel 4.4: Antwoorden van studenten op de vraag “Wat zou je verbeteren aan de gegeven
grafieken?”
45
geeft het probleem door aan de BioPython library. De automatische inschatting geeft een
geheugencomplexiteit van O(m2 ∗ n2) met een betrouwbaarheid van 0.9940, maar de be-
trouwbaarheid van O(m×n) is 0.9930. Het is hier dus niet meteen duidelijk wat het juiste
antwoord is, maar het lijkt ons zeer onwaarschijnlijk dat de BioPython implementatie als
tijdscomplexiteit O(m2 ∗ n2) zou hebben.
Voor de geheugencomplexiteitsfuncties kwam voor 34 van 53 oplossingen de complexi-
teit ingeschat door de methode en de complexiteit ingeschat door de studenten volledig
overeen. Van de negentien oplossingen waar niet alle manueel ingeschatte complexiteiten
overeen komen met de automatisch ingeschatte complexiteit waren er vijf waar er maar één
manuele inschatting niet overeen kwam. Verdere inspectie van deze inschattingen maakte
duidelijk dat de automatische inschatting juist is en de studenten een foute inschatting
gemaakt hadden. Bij de veertien resterende oplossingen waren er nooit 2 studenten die
dezelfde inschatting hadden gemaakt. Bij acht van deze oplossingen geeft de automatische
inschatting van de complexiteit een duidelijk antwoord, dat ook lijkt te kloppen als we
zelf de oplossing bekijken. Voor de resterende zes oplossingen geeft ook de automatische
inschatting geen duidelijk antwoord: de betrouwbaarheden zijn vrij laag of liggen dicht
bij elkaar. Hierbij valt het ook op dat de studenten vooral bij deze oplossingen als in-
schattingen complexiteitsfuncties hebben gegeven waar de automatische inschatter niet
op testte (bijvoorbeeld O(m+ n)). Er is één oplossing waar de automatische inschatting
er zelfs niet in geslaagd is om tot een zinnig antwoord te komen. Hierbij komt voor geen
enkele complexiteitsfunctie de betrouwbaarheid boven de 0.1 uit. Dit is echter ook niet
onlogisch, aangezien het geheugengebruik zich vreemd gedraagt. Zo heeft de code voor
de parameters (2500, 8000) een maximum geheugengebruik gehad van 1.9 MB, maar voor
de parameters (3000, 8500) een maximum geheugengebruik gehad van 1.0 MB.
46
Hoofdstuk 5
Toekomstig werk
Uit het gebruik van Jupyter kernels voor het uivoeren van code die op Dodona wordt
ingediend, ontstond het idee voor het ontwikkelen van TESTed, een framework voor
het educatief test van software dat moet toelaten om programmeeropdrachten op een
programmeertaal-onhafhankelijke manier te evalueren. Naast feedback over correctheid
zal TESTed ook feedback geven over computationele complexiteit van ingediende oplos-
singen, volledigheid van een testsuite inschatten en testen genereren op basis van voor-
beeldoplossingen.
In dit afsluitende hoofdstuk geven we een korte bespreking van het TESTed framework.
5.1 Probleemstelling
Algemeen gezien wordt het testen van software uitgevoerd om de kwaliteit van de software
die getest wordt in te schatten. Wanneer dit gebeurt in een educatieve context — waarbij
studenten oplossingen indienen voor specifieke programmeeropdrachten — dan spreken
we van het educatief testen van software (educational software testing). Het educatief
testen van software vindt zelf zijn oorsprong in programmeren in competitieverband,
waarbij deelnemers van programmeerwedstrijden software moeten schrijven die voldoet
aan bepaalde specificaties. Daarbij worden de ingediende oplossingen door een judge
afgetoetst aan een vooraf gedefinieerde reeks van testgevallen. Bij wedstrijden blijven
deze testgevallen en de individuele resultaten van de testen geheim. In een educatieve
context krijgen judges de bijkomende taak om nuttige en uitgebreide feedback te geven
die de studenten naar een correcte oplossing moet leiden.
47
Bij het testen van software is één van de doelstellingen van de ontwikkelaars van judges
om de evaluatie onafhankelijk te maken van de taal waarin de oplossing geprogrammeerd
werd. Hiervoor bestaan verschillende strategieën: triviaal is bijvoorbeeld om invoer te
geven aan de programmeur, die dan de corresponderende uitvoer moet indienen. Hierbij
moet de judge de code niet zelf uitvoeren. Een uitbreiding hiervan — die in de praktijk het
vaakst gebruikt wordt — is om de ingediende programmacode te evalueren via IO: de judge
voert de ingediende programmacode uit, waarbij het proramma invoer in tekstformaat kan
inlezen vanaf stdin en de corresponderende uitvoer in tekstformaat dient uit te schrijven
naar stdout. In een educatieve context leggen beide strategieën echter grote restricties
op, zowel aan de manier waarop de oplossing moet geïmplementeerd worden maar vooral
aan de diepgang van feedback die kan geproduceerd worden. Bij de eerste strategie kan er
helemaal geen feedback gegeven worden over de kwaliteit van de software zelf. De tweede
aanpak verplicht de ingediende software om gegevens te lezen uit stdin en resultaten te
schrijven naar stdout, wat deels de focus wegneemt van de eigenlijke opdracht en weinig
aanknopingspunten biedt om gedetailleerde feedback te geven.
De moderne softwareontwikkelingspraktijk is uitermate test-gedreven (Beck (2003)).
Dit heeft ervoor gezorgd dat er zeer veel tools en frameworks bestaan om software te tes-
ten. Het gebruik hiervan in een educatieve context heeft echter als belangrijkste beperking
dat alle tools en frameworks programmeertaalafhankelijk zijn. Het intelligente tutoring-
systeem Dodona (dodona.ugent.be) dat werd ontwikkeld aan het Computational Biology
Lab van de Universiteit Gent heeft als doel om enkele problemen met bestaande educa-
tieve judges aan te pakken. Op dit moment worden er in Dodona echter nog altijd een
afzonderlijke judges geschreven voor elke nieuwe programmeertaal die ondersteund wordt.
Deze judges delen heel veel eigenschappen en hebben vaak een gelijkaardige architectuur,
waardoor het implementeren van nieuwe judges resulteert in veel dubbel werk.
5.2 Doelstelling
De opzet van dit onderzoek is om een programmeertaal-onafhankelijke judge te schrijven
voor Dodona, die moet toelaten om met één enkele specificatie van een programmeerop-
dracht, oplossingen te evalueren die in een groot aantal programmeertalen kunnen inge-
diend worden. Naast het evalueren van de correctheid van de ingediende oplossingen, zal
48
dit programmeertaal-onafhankelijk framework voor het educatief testen van software –
TESTed genaamd – ook een inschatting kunnen maken van de computationele complexi-
teit (tijds- en geheugengebruik), analyses kunnen uitvoeren van de compleetheid van de
testen en automatisch testgevallen kunnen genereren op basis van voorbeeldoplossingen.
5.3 Methodologie
De innovatieve architectuur van TESTed (Figuur 5.1) scheidt het plannen van de tests
en het genereren van de feedback van het uitvoeren van de programmacode en het eva-
lueren van de toestand na het uitvoeren van een testgeval. Het uitvoeren van de code
gebeurt door een zogenaamde execution kernel. Dit is in essentie een Jupyter kernel, de
centrale component die gebruikt wordt door Jupyter Notebooks (Kluyver et al. (2016)).
Hierdoor komt het ondersteunen van nieuwe programmeertalen voor TESTed neer op het
ontwikkelen van Jupyter kernels voor die talen. Op dit moment staan er al meer dan 100
Jupyter kernels officiëel geregistreerd (Project Jupyter (2018)).
Er zijn drie manieren waarop TESTed software kan evalueren. De eerste manier is het
uitvoeren van testen op de code binnen de execution kernel. Dit correspondeert met de
traditionele manier van het testen van software, waar de software en het testframework in
dezelfde taal geschreven zijn. Op die manier garandeert TESTed dat testen die inherent
programmeertaal-specifiek zijn nog steeds ondersteund worden. Het doel van werkpakket
1 (WP1) is om een werkend prototype van TESTed te ontwikkelen zoals beschreven in
Figuur 5.1. Hierbij geldt wel de restrictie dat een test nog steeds voor een specifieke
programmeertaal moet geschreven worden. Dit zal ons echter toelaten om het prototype
in de praktijk te gebruiken als proof-of-concept voor de voorgestelde architectuur van
TESTed. We beschikken immers over een grote verzameling programmeeropdrachten
waarvan we kunnen evalueren hoe gemakkelijk ze kunnen overgezet worden naar TESTed.
Tegelijkertijd zullen we ook op zoek gaan naar de gemeenschappelijke eigenschappen en de
verschillende teststrategieën in de bestaande Dodona judges. Daarnaast zal het initiële
prototype van TESTed dienen als basis voor het onderzoek en de ontwikkeling van de
andere drie werkpakketten.
Ik heb er uitermate veel vertrouwen in dat de voorgestelde architectuur van TESTed
ook in de praktijk zal werken. Ik maak immers ook gebruik maak Jupyter kernels in mijn
49
Figuur 5.1: Architectuur van het software testing framework TESTed die het inplannen
van testen en het genereren van feedback scheidt van het uitvoeren van de code en de
testen en het evalueren van de toestand na het uitvoeren van de testen.
50
masterproef, waar ik computationele benaderingen voor het inschatten van computatio-
nele complexiteit aan het onderzoeken ben. Hiervoor heb ik al een uitbreiding van de
Jupyter kernels ontwikkeld die tijds- en geheugenmetingen uitvoert op de uitgevoerde
code. Dit onderzoek zal verder uitgewerkt worden in werkpakket 2 (WP2) maar is ook
de inspiratie geweest voor het uittekenen van de architectuur van TESTed.
Het uiteindelijke doel van TESTed is om zoveel mogelijk softwaretesten te kunnen
uitvoeren op een manier die onafhankelijk is van de programmeertaal van de ingediende
oplossing. Het verkennen van dit idee is de focus van een derde werkpakket (WP3),
waar we zowel abstracte evaluatie als evaluatie binnen een afzonderlijke evaluation ker-
nel zullen onderzoeken. Deze twee benaderingen voor het evalueren van programma-
code vereisen wederzijdse transformaties tussen de programmeertaal-specifieke voorstel-
ling van de toestand na het uivoeren van een test op de programmacode en een abstracte
programmeertaal-onafhankelijke representatie hiervan.
De ervaring van het Computational Biology Lab met het implementeren van Dodona
judges en het ontwikkelen van programmeeropdrachten, leert ons dat de meeste opdrach-
ten voor educatieve doeleinden zich zeer goed lenen om zowel de verwachte als de ge-
genereerde toestand na het uitvoeren van een test op de ingediende programmacode te
vertalen naar een abstracte (programmeertaal-onafhankelijke) voorstelling. Om dit uit
te werken en aan de praktijk af te toetsen, kunnen we gebruik maken van de meer dan
2.000 programmeeropdrachten die reeds voor Dodona ontwikkeld werden en van de meer
dan 2 miljoen oplossingen die reeds werden ingediend voor deze opdrachten. Op basis
daarvan kunnen we experimenteel de mogelijke beperkingen van de abstracte voorstelling
in kaart brengen, om zo het abstractieproces gradueel te verfijnen en uit te breiden. We
verwachten dat slechts voor een aantal specifieke opdrachten zal blijken dat de verwach-
te/gegenereerde toestand moeilijk te abstraheren valt, of dat die zo programmeertaal-
specifiek is dat het niet de moeite loont om daarvoor een abstracte voorstelling te maken.
Voor deze gevallen blijft immers altijd de mogelijkheid bestaan om een programmeertaal-
specifieke evaluatie uit te voeren binnen de execution kernel (WP1).
Jupyter kernels zijn uitermate uitbreidbaar en hun integratie in de architectuur van
TESTed laat toe om bepaalde aspecten van het testen van software op een generieke ma-
nier te implementeren. Als concreet voorbeeld hiervan zullen we in WP2 een uitbreiding
ontwikkelen van de execution kernel, waarmee de computationele complexiteit van inge-
51
diende oplossingen kan ingeschat worden. Dit deel van het project is een verderzetting
van het onderzoek dat ik momenteel uitvoer in het kader van mijn masterproef. Voor het
inschatten van de computationele complexiteit voeren we een lineaire regressie-analyse
uit tussen de gemeten resultaten en abstracte getallen die berekend worden uit de functie
van de verwachte complexiteit en de complexiteitsparameters van de uitgevoerde testen.
In het laatste werkpakket (WP4) focussen we tenslotte op het proces waarmee testen
gespecificeerd worden. Omdat zelfs voor eenvoudige software het aantal mogelijke testen
schier oneindig is, moet voor het testen van software een strategie gebruikt worden om tes-
ten te selecteren die binnen een realistische tijd kunnen uitgevoerd worden én die de kans
maximaliseren om bugs of andere defecten in de software te vinden. Vertrekkend van de
test coverage analyse voor een verzameling van 700+ bestaande programmeeropdrachten
en hun voorbeeldoplossingen in Python, onderzoeken we verschillende strategieën waarmee
TESTed op een dynamische manier testen kan genereren, complementair aan de manueel
gegenereerde testen die nu gebruikt worden. In het bijzonder laten we ons hiervoor inspi-
reren door symbolische uitvoering (King (1976); Khurshid et al. (2003)), bounded model
checking (Anielak et al. (2015)) en abstracte interpretatie (Nielson (1989)).
52
Hoofdstuk 6
Conclusie
In deze thesis hebben we een techniek ontwikkeld om automatisch tijds- en geheugen-
metingen te nemen. Deze metingen kunnen genomen worden op een programmeertaal-
onafhankelijke manier. Verder ontwikkelden we een techniek om de computationele com-
plexiteit van programma’s in te schatten. De methode werkt goed voor de tijdscomplexi-
teit en redelijk goed voor de geheugencomplexiteit. Voor complexiteitsfuncties kleiner
dan O(n) zorgt de overhead van de Jupyter kernels er voor dat de metingen niet accuraat
genoeg genomen kunnen worden om de complexiteit goed in te schatten. Een bevra-
ging bij studenten toonde aan dat de metingen een nuttig instrument vormen om code te
beoordelen en om de complexiteit van de code manueel in te schatten.
Mogelijke verbeteringen bevinden zich zo goed als allemaal in het verbeteren van
de nauwkeurigheid van de metingen. Zo zou bijvoorbeeld de lineaire overhead van de
Jupyter kernel wegwerken toelaten om de complexiteitsfuncties kleiner dan O(n) beter in
te schatten.
53
Bibliografie
Anielak, G., Jakacki, G. and Lasota, S. (2015), ‘Incremental test case generation using
bounded model checking: an application to automatic rating’, International Journal on
Software Tools for Technology Transfer 17(3), 339–349.
Beck, K. (2003), Test-driven development: by example, Addison-Wesley Professional.
Danielsson, N. A. (2008), ‘Lightweight semiformal time complexity analysis for purely
functional data structures’, SIGPLAN Not. 43(1), 133–144.
Gale, D. and Shapley, L. S. (1962), ‘College admissions and the stability of marriage’,
The American Mathematical Monthly 69(1), 9–15.
Goldsmith, S. F., Aiken, A. S. and Wilkerson, D. S. (2007), Measuring empirical computa-
tional complexity, in ‘Proceedings of the the 6th Joint Meeting of the European Software
Engineering Conference and the ACM SIGSOFT Symposium on The Foundations of
Software Engineering’, ESEC-FSE ’07, ACM, New York, NY, USA, pp. 395–404.
Gulwani, S., Mehra, K. K. and Chilimbi, T. (2009), ‘Speed: Precise and efficient static
estimation of program computational complexity’, SIGPLAN Not. 44(1), 127–139.
Healy, C., Sjödin, M., Rustagi, V., Whalley, D. and Engelen, R. v. (2000), ‘Suppor-
ting timing analysis by automatic bounding of loop iterations’, Real-Time Systems
18(2), 129–156.
Hirschberg, D. S. (1975), ‘A linear space algorithm for computing maximal common sub-
sequences’, Communications of the ACM 18(6), 341–343.
Kapur, D. and Krishnamoorthy, M. S. (1985), ‘Worst-case choice for the stable marriage
problem’, Information processing letters 21(1), 27–30.
54
Khurshid, S., Păsăreanu, C. S. and Visser, W. (2003), Generalized symbolic execution for
model checking and testing, in ‘International Conference on Tools and Algorithms for
the Construction and Analysis of Systems’, Springer, pp. 553–568.
King, J. C. (1976), ‘Symbolic execution and program testing’, Communications of the
ACM 19(7), 385–394.
Kluyver, T., Ragan-Kelley, B., Pérez, F., Granger, B. E., Bussonnier, M., Frederic, J.,
Kelley, K., Hamrick, J. B., Grout, J., Corlay, S. et al. (2016), Jupyter notebooks-a
publishing format for reproducible computational workflows., in ‘ELPUB’, pp. 87–90.
McGeoch, C. C. (1987), Experimental analysis of algorithms., Technical report, Carnegie-
Mellon Univ Pittsburgh PA Dept Of Computer Science.
McGeoch, C., Sanders, P., Fleischer, R., Cohen, P. R. and Precup, D. (2002), Using
finite experiments to study asymptotic performance, in ‘Experimental algorithmics’,
Springer, pp. 93–126.
Nielson, F. (1989), ‘Two-level semantics and abstract interpretation’, Theoretical Compu-
ter Science 69(2), 117–242.
Project Jupyter (2018), ‘List of available jupyter kernels’.
URL: https://github.com/jupyter/jupyter/wiki/Jupyter-kernels
55