5.4 Stykkvise og diskontinuerlige funksjoner
Representere stykkvise funksjoner (Heaviside, Piecewise) — ofte som en klasse med betinget logikk i `__call__`.
- Stykkvise og diskontinuerlige funksjoner — funksjoner som er definert med forskjellige uttrykk i forskjellige intervaller — er med i 63 % av eksamenssettene, som regel som en klasse med betinget logikk. Det siste kjente tilfellet er en generell Piecewise-klasse.
- Formen er sjanger G: du skriver en klasse med konstruktør og kallmetode, som i kap. 5.1. Det nye er bare at kallmetoden må velge mellom flere uttrykk.
- Prioritet: nivå 2 — dette bør sitte. Boka bruker tre prioritetsklasser: nivå 1 «må sitte», nivå 2 «bør sitte», nivå 3 «bør du kjenne til». Stykkvise funksjoner er nivå 2: de er ikke det tyngste på settet, men de er billige poeng, fordi klassen er kort.
- Dette kapitlet gir deg også et verktøy du bruker senere: en pådragsfunksjon som slås av og på, er nettopp det du trenger som kildeledd i en differensiallikning i Del 7.
if/elif/else og enumerate fra kap. 1.3, og — helt til slutt i kapitlet, som «bør kjenne til»-stoff — vektorisering fra kap. 4.2.Her er den betingede logikken oppfrisket, i en helt vanlig funksjon:
def tariff(kwh):
if kwh < 1000:
return 0.4
elif kwh < 5000:
return 0.6
else:
return 0.9
for k in [500, 1000, 4999, 5000]:
print(k, tariff(k))Utskrift:
500 0.4
1000 0.6
4999 0.6
5000 0.9Studer utskriften nøye, for den viser fellen du skal møte igjen om et øyeblikk: verdien 1000 gir 0.6, ikke 0.4. Grensen kwh < 1000 er eksklusiv — 1000 er ikke mindre enn 1000, så første gren hoppes over.
Det andre du trenger, er at enumerate(liste) gir deg indeks og verdi samtidig, i den rekkefølgen: for i, verdi in enumerate(...).
Løkke 1 — Funksjoner som hopper (~15 min)
Problemet: pumpa som slås på
En pumpe i et vannmagasin står stille fram til tidspunktet , og går deretter med full kapasitet. Pådraget som funksjon av tiden er ikke en formel — det er to formler, med et hopp mellom dem:
Slike funksjoner er overalt i naturvitenskapen: en dose gis på ett tidspunkt, en bryter slås på, en strømtariff endrer seg ved et forbrukstrinn, en kraft virker bare i et intervall.
Python har ingen egen syntaks for dem. Det du har, er if, og det er nok — enten i en vanlig funksjon eller, når parametrene skal kunne varieres, i en __call__.
Tre ting må være på plass for at definisjonen skal være entydig, og de er nøyaktig de tre du må kontrollere i koden din:
1. Hele definisjonsområdet er dekket — det finnes ingen som faller mellom to intervaller.
2. Ingen hører til to intervaller — grensene overlapper ikke.
3. Hver grense hører til én av sidene — det er derfor det står i den ene linjen og i den neste.
I Python blir hver linje i klammeparentesen én gren i en if/elif/else, i samme rekkefølge.
Den er oppkalt etter Oliver Heaviside og brukes til å slå noe på: ganger du en formel med , virker formelen først fra tidspunktet .
Merk konvensjonen: verdien i null er 1, ikke 0 og ikke . Andre fag setter ; i denne boka følger vi oppgavetekstens definisjon, og den sier . Sier oppgaven noe annet, følger du oppgaven.
Skriv Heaviside-funksjonen både som en vanlig funksjon H(x) og som en klasse Heaviside der påslagstidspunktet t0 gis i konstruktøren og har standardverdi 0. Skriv ut verdiene rundt hoppet.
def H(x):
if x < 0:
return 0
else:
return 1
class Heaviside:
def __init__(self, t0=0):
self.t0 = t0
def __call__(self, t):
if t < self.t0:
return 0
else:
return 1
paa = Heaviside(2)
for x in [-1, -0.001, 0, 1]:
print(x, H(x))
for t in [0, 1.999, 2, 5]:
print(t, paa(t))Utskrift:
-1 0
-0.001 0
0 1
1 1
0 0
1.999 0
2 1
5 1Sammenlign de to. Den vanlige funksjonen er kortere, og du skal velge den når hoppet alltid ligger i null. Klassen er den du velger når hoppets plassering er en parameter — nettopp den situasjonen funksjonsklassen er laget for.
Se på tallene rundt hoppet: gir 0, mens gir 1. Det er <-grensen som avgjør, og det er verdt å sjekke nøyaktig dette punktet hver gang du har skrevet en stykkvis funksjon.
Legg også merke til at paa(1.999) er 0 og paa(2) er 1: klassen flytter hele hoppet til t0 uten at en eneste linje i logikken endres.
Uttelling: oppgaven ber ofte bare om funksjonen. Ber den om klassen, er de to poengbærende linjene self.t0 = t0 i konstruktøren og if t < self.t0 i kallmetoden — det siste med self. foran, som alltid.
Mønsteret som oversetter en klammeparentes til kode. Grenene prøves ovenfra og ned, og den første som er sann, brukes — de andre hoppes over.
def f(x):
if x < 1:
return 0
elif x < 3:
return 5
else:
return 2Fordi grenene prøves i rekkefølge, trenger du ikke skrive den nedre grensen på nytt: kommer du til elif x < 3, vet du allerede at . Det er derfor koden er kortere enn matematikken.
Rekkefølgen er dermed avgjørende. Snur du de to første grenene, får alle under 3 verdien 5, og den første grenen blir aldri brukt.
(Innstegsoppgave, sjanger A — kodesporing, altså «hva skriver programmet ut?».) Skriv utskriften nøyaktig, med linjeskift slik terminalen viser den.
def sats(x):
if x < 0:
return -1
elif x < 10:
return 0
else:
return 1
for x in [-0.5, 0, 9.99, 10, 100]:
print(x, sats(x))Dette er den ene feilen som virkelig koster i stykkvise funksjoner, og den viser seg bare i ett punkt — grensen selv. En rask test med tre tilfeldige verdier avslører den ikke.
Anta at funksjonen skal være 5 på intervallet . To skrivemåter ser like ut og er det ikke:
if x <= 1: # feil: x = 1 havner i FORRIGE gren
return 0
elif x < 3:
return 5Med <= i første gren får verdien 0, mens definisjonen sier 5.
Metoden som fjerner feilen: skriv opp de eksakte grensepunktene før du koder — her 1 og 3 — og regn ut hva funksjonen skal være i nøyaktig de punktene. Test deretter koden i de samme punktene. To linjer ekstra i hodet, og du er trygg.
Regelen som holder i nesten alle oppgavetekster: skriv grensen som < og la likheten høre til grenen etter. Da samsvarer koden med den vanlige matematiske skrivemåten .
Løkke 2 — Piecewise: én klasse for alle trapper (~20 min)
Når antall intervaller er en parameter
if/elif/else fungerer så lenge du kjenner intervallene når du skriver koden. Men en strømtariff kan ha fem trinn i år og syv til neste år, og da vil du ikke skrive klassen om.
Løsningen er å gi funksjonsverdiene og grensene som lister til konstruktøren:
Med a = [0, 5, 2] og b = [1, 3] er dette nøyaktig trappa fra i stad. Legg merke til antallene: tre verdier krever to grenser.
Kallmetoden løper gjennom grensene ovenfra og ned og returnerer den første verdien som passer. Faller forbi alle grensene, er svaret den siste verdien.
Konstruktøren tar to lister: a er funksjonsverdiene, b er grensene. Begge lagres som attributter, og ingenting mer skjer.
class Piecewise:
def __init__(self, a, b):
self.a = a
self.b = bRekkefølgen i listene er bindende: a[i] er verdien på intervallet som slutter ved b[i], og listene skal være sortert stigende i .
Dette er funksjonsklasse-malen helt uendret — det faste (trappa) i konstruktøren, det variable () i kallmetoden. Det nye er bare at «det faste» er to lister i stedet for to tall.
Dette er det kanoniske mønsteret for stykkvise funksjoner i IN1900, og det er verdt å kunne utenat — det er fire linjer:
def __call__(self, x):
for i, b_ in enumerate(self.b):
if x < b_:
return self.a[i]
return self.a[-1]Slik virker det, linje for linje:
1. enumerate(self.b) gir deg indeksen i og grensen b_ samtidig, ovenfra og ned.
2. if x < b_ spør om ligger under denne grensen. Er svaret ja, er vi i intervall nummer i.
3. return self.a[i] returnerer verdien for det intervallet — og return avbryter hele løkka, så ingen senere grense blir prøvd.
4. return self.a[-1] utenfor løkka er siste intervall: kom vi hit, var større enn eller lik alle grensene.
Understreken i b_ er bare et navnevalg for å unngå å bruke navnet b, som ofte er tatt. Den har ingen egen betydning i Python.
Den siste linjen er den som glemmes. Uten den returnerer metoden None for store -verdier, og programmet krasjer først senere — når noen prøver å regne med None.
Hjertet i Piecewise. Løkka går gjennom grensene i rekkefølge, og return inne i løkka gjør at den stopper ved den første grensen som er større enn .
At return avbryter løkka, er det som gjør at koden svarer på samme spørsmål som if/elif/else: bare den første treffen gjelder.
Indeksen fra enumerate er bindeleddet mellom de to listene: grense nummer i hører til verdi nummer i. Bytter du om og skriver self.a[i+1], forskyver du hele trappa ett trinn.
Linjen som står utenfor løkka og fanger opp alle som er større enn eller lik den siste grensen.
Negativ indeksering [-1] betyr «siste element», og det er nettopp poenget: du vet ikke hvor mange trinn trappa har, men du vet at siste verdi er den øverste.
Glemmer du linjen, returnerer metoden None for store . Symptomet er typisk TypeError: unsupported operand type(s) for *: 'NoneType' and 'float' et helt annet sted i programmet, og det er en ubehagelig feil å finne. Skriv linjen med én gang, sammen med løkka.
Sammenhengen mellom listene: grenser deler tallinja i intervaller, så det skal alltid være én funksjonsverdi mer enn antall grenser.
a = [0, 5, 2] og b = [1, 3]: tre verdier, to grenser. Det stemmer.
Dette er samme av-én-tenkning som i range og np.linspace (feil #1 i bokas feilkatalog — av-én i antall punkter). Har du like mange verdier som grenser, mangler du enten et intervall eller en grense, og feilen viser seg som IndexError eller som en verdi som aldri blir brukt.
En rask kontroll før du kjører: tell elementene i de to listene, og se at differansen er 1.
Skriv den generelle klassen Piecewise med funksjonsverdier og grenser som lister, og bruk den på en tariff der prisen er 0,40 kr/kWh under 1000 kWh, 0,60 kr/kWh fra 1000 til under 5000, og 0,90 kr/kWh fra 5000 og opp.
class Piecewise:
def __init__(self, a, b):
self.a = a
self.b = b
def __call__(self, x):
for i, b_ in enumerate(self.b):
if x < b_:
return self.a[i]
return self.a[-1]
tariff = Piecewise([0.4, 0.6, 0.9], [1000, 5000])
for k in [0, 999, 1000, 4999, 5000, 20000]:
print(k, tariff(k))Utskrift:
0 0.4
999 0.4
1000 0.6
4999 0.6
5000 0.9
20000 0.9Utskriften er hele kvitteringen på at koden er riktig, og den er verdt å lese grense for grense:
- 999 gir 0.4, 1000 gir 0.6 — grensen hører til intervallet over, som i definisjonen.
- 4999 gir 0.6, 5000 gir 0.9 — samme mønster ved neste grense.
- 20000 gir 0.9, og den verdien kommer fra return self.a[-1] utenfor løkka.
Klassen inneholder ikke ett tall om strøm. Den er ren struktur, og det er derfor samme fire linjer kan brukes på en tariff, en trappefunksjon eller et pådrag som slås av og på.
Uttelling: løkka med enumerate og return self.a[i] er hovedpoenget; return self.a[-1] utenfor løkka er det som skiller en komplett besvarelse fra en som virker «nesten alltid». Skriver du i stedet en if/elif/else med faste grenser, får du delvis uttelling — men oppgaven ba om den generelle klassen.
(Midtveisnivå, sjanger A — kodesporing.) Klassen Piecewise er som i eksempel 2. Hva skriver programmet ut?
class Piecewise:
def __init__(self, a, b):
self.a = a
self.b = b
def __call__(self, x):
for i, b_ in enumerate(self.b):
if x < b_:
return self.a[i]
return self.a[-1]
f = Piecewise([10, 20, 30, 40], [0, 5, 10])
print(f(-3))
print(f(0))
print(f(5))
print(f(9.5))
print(f(10))En trappefunksjon er 0 for , 5 for og 2 for .
a) Skriv en klasse Trapp der funksjonsverdiene og grensene gis som to lister i konstruktøren, og der kallet returnerer riktig verdi.
b) Lag instansen som svarer til trappa over, og skriv ut verdien i , , , og .
c) Hvor mange grenser trenger en trapp med fem verdier? Begrunn med én setning.
Løkke 3 — Diskontinuitet, og et vektorisert alternativ (~10 min)
Et punkt der funksjonen hopper: verdien rett før grensen og verdien i grensen er forskjellige, uansett hvor nær du kommer.
Trappa vår hopper i og i . Mellom hoppene er den konstant, og der er den helt ufarlig.
Diskontinuiteter har to praktiske konsekvenser du bør kjenne:
- Numerisk derivasjon virker ikke i hoppet. Formelen gir en enorm verdi som vokser når krymper, fordi teller er konstant og nevner går mot null.
- Plott ser rare ut. Plotter du en stykkvis funksjon med for få punkter, tegner plot en skrå strek gjennom hoppet, som om funksjonen var kontinuerlig. Det er en tegneartefakt, ikke funksjonen.
Det vektoriserte alternativet: velger elementvis mellom to verdier ut fra en betingelse, og returnerer et helt array.
import numpy as np
t = np.linspace(-1, 1, 5)
print(np.where(t < 0, 0, 1))Utskrift:
[0 0 1 1 1]Dette er samme jobb som Heaviside gjør, men på alle punktene på én gang — nyttig når du skal plotte.
Dette er «bør kjenne til»-stoff (nivå 3). Klassevarianten med if er den som spørres om på eksamen, og den er også den du skal skrive. Grunnen til at np.where nevnes, er at en if-basert funksjon ikke virker på et array: du får ValueError: The truth value of an array with more than one element is ambiguous. Kjenner du den feilmeldingen igjen, vet du hva som skjedde.
Du har trappa fra eksempel 2 som en Piecewise-instans, f = Piecewise([0, 5, 2], [1, 3]), og skal lage en verditabell over 401 punkter mellom 0 og 4 — den du trenger for å plotte trappa.
a) Du skriver x = np.linspace(0, 4, 401) og deretter y = f(x). Hva skjer, og hvorfor?
b) Skriv koden som bygger y riktig, slik at den blir like lang som x. Kontroller lengdene og verdiene i , og .
c) Et pådrag slås på ved : verdien er 0 før og 1 fra og med. Skriv den vektoriserte varianten av nettopp den funksjonen for de fem tidspunktene 0, 1, 2, 3 og 4, på én linje. Hvorfor er ikke den varianten svaret sensor er ute etter for Piecewise?
I en dyrkningsforsøk tilføres et næringsstoff med rate 0 før , rate 10 fra til , og rate 4 etter det. Stoffet brytes samtidig ned med en avtakende faktor . Skriv en klasse som kombinerer det stykkvise pådraget med nedbrytningen, og bruk Piecewise som en del av den.
class Piecewise:
def __init__(self, a, b):
self.a = a
self.b = b
def __call__(self, x):
for i, b_ in enumerate(self.b):
if x < b_:
return self.a[i]
return self.a[-1]
from math import exp
class Dosert:
def __init__(self, doser, tider, halveringstid):
self.paadrag = Piecewise(doser, tider)
self.halveringstid = halveringstid
def __call__(self, t):
return self.paadrag(t)*exp(-t/self.halveringstid)
d = Dosert([0, 10, 4], [2, 8], 6)
for t in [0, 2, 5, 8, 12]:
print(f'{t:3d} {d(t):8.3f}')Utskrift:
0 0.000
2 7.165
5 4.346
8 1.054
12 0.541Se hva som skjer i konstruktøren: self.paadrag er en instans av Piecewise. Et attributt kan være et objekt, og det objektet er kallbart — så self.paadrag(t) inne i __call__ er et helt vanlig funksjonskall.
Dette er mønsteret du får bruk for i Del 7: en stykkvis kildefunksjon som del av høyresiden i en differensiallikning. Da er det nettopp self.paadrag(t) som står i formelen.
Utskriften viser hoppet: ved slås pådraget på, og verdien går fra 0 til noe positivt. Innenfor hvert intervall avtar verdien jevnt, fordi nedbrytningsfaktoren er kontinuerlig.
Uttelling: at pådraget gjenbrukes som en Piecewise-instans i stedet for å kopieres inn som if-er, er det sensor legger merke til. Skriver du i stedet if/elif direkte inne i __call__, er svaret riktig og uttellingen litt lavere — det viser mindre struktur.
En kraft virker på en gjenstand bare i et tidsvindu: den er 0 før , konstant mellom og , og 0 etter .
a) Skriv en klasse Pulskraft der , og gis i konstruktøren, og der kallet gir kraften ved tiden t. Bruk if/elif/else.
b) Skriv den samme funksjonen på nytt, men nå ved å bruke den generelle Piecewise-klassen inne i din egen klasse. Hvilke to lister trenger du?
c) Skriv ut kraften ved for , og , med begge variantene, og kontroller at de gir samme svar.
Klassen under skal være den generelle stykkvise funksjonen, men den har tre feil. Finn dem, si hva hver av dem gjør, og skriv klassen riktig.
class Piecewise:
def __init__(self, a, b):
a = a
b = b
def __call__(self, x):
for i, b_ in enumerate(self.b):
if x <= b_:
return self.a[i+1]Bruk gjerne testen f = Piecewise([0, 5, 2], [1, 3]) og verdiene når du forklarer.
Begrepsbank
Begrepsbanken er flashcard-/repetisjonsstoff — den gjentar det du nettopp har lest. Hopp trygt over ved førstegangslesing; tidsanslaget for kapitlet gjelder kjernestoffet.
En stykkvis funksjon som er konstant på hvert intervall. Grafen ser ut som en trapp: vannrette trinn med loddrette hopp mellom.
Strømtariffen, pulskraften og Heaviside er alle trappefunksjoner. Det er den typen Piecewise er laget for: klassen lagrer én verdi per intervall, ikke ett uttrykk per intervall.
Skal hvert intervall ha sin egen formel og ikke bare sin egen verdi, må du bruke if/elif/else med uttrykk i hver gren — eller lagre funksjoner i lista a og kalle self.a[i](x). Det siste er elegant, men oppgavene ber sjelden om det.
Gir deg indeks og verdi samtidig, i den rekkefølgen: for i, verdi in enumerate(liste).
Rekkefølgen er verdt å dobbeltsjekke, for den er en fast eksamensfelle (#17 i bokas feilkatalog): det er (indeks, verdi), ikke (verdi, indeks).
I Piecewise er enumerate det som binder de to listene sammen. Uten indeksen måtte du telle selv med en teller før løkka, og det er nettopp den typen ekstra kode sensor ikke vil se.
En Python-funksjon som når slutten uten å møte en return, returnerer None. Det er ingen feil der og da — feilen kommer senere, når None brukes i et regnestykke.
I stykkvise funksjoner er dette den vanligste stille feilen: return self.a[-1] utenfor løkka mangler, og alt virker helt til noen kaller funksjonen med en stor -verdi.
Symptomet er en TypeError som nevner 'NoneType'. Ser du den, er spørsmålet alltid: hvilken gren i funksjonen min mangler en return?
Vanen som fjerner nesten alle feil i stykkvise funksjoner: test i nøyaktig grensepunktet, og i punktet rett under.
For trappa med grense 3 betyr det å regne ut og . Er begge som definisjonen sier, er den grensen riktig.
Tre tilfeldige testverdier avslører ikke en <-mot-<=-feil, fordi den bare gir gale svar i ett enkelt punkt. Grensetesting koster to sekunder og er den beste egensjekken i dette kapitlet — også på eksamen, der du regner den i hodet.
En if-setning krever ett sant/falskt svar. Gir du den et array med flere elementer, vet Python ikke om «arrayet er sant», og sier fra:
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()Det betyr at en stykkvis funksjon skrevet med if må kalles ett punkt av gangen, typisk i en løkke over punktene.
Vil du ha svaret for alle punktene på én gang — for eksempel til et plott — er np.where verktøyet. Det er «bør kjenne til»-stoff; eksamen spør etter if-varianten.
self.paadrag = Piecewise(a, b) lagrer en hel instans i et attributt. Siden Piecewise har en __call__, kan du senere skrive self.paadrag(t) som et vanlig funksjonskall.Dette er samme idé som Diff i kap. 5.1, der attributtet var en funksjon. Forskjellen er bare at objektet her er en instans av en av dine egne klasser.
Mønsteret er verdt å kjenne, for det er slik en stykkvis kildefunksjon settes inn i høyresiden av en differensiallikning i Del 7 — uten at ODE-koden trenger å vite noe om intervaller.
Dette kapitlet er skrevet av Anthropics toppmodeller (Claude Opus og Claude Fable) og er foreløpig ikke manuelt gjennomgått — kvalitetskontrollen gjøres av uavhengige KI-agenter, og innmeldte feil rettes fortløpende. Funnet en feil? Meld fra, så retter vi den. Les mer om hvordan innholdet lages.
Skolesaga er en uavhengig læringsressurs og er ikke tilknyttet eller godkjent av Universitetet i Oslo. Dette er ikke offisielt studiemateriell. Les mer.