lrsched 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lrsched/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Framework-agnostic learning-rate schedules as pure functions, with zero dependencies."""
2
+
3
+ from ._types import Schedule
4
+ from .compose import sample, sequential, with_warmup
5
+ from .schedules import (
6
+ constant,
7
+ cosine,
8
+ cosine_restarts,
9
+ exponential,
10
+ inverse_sqrt,
11
+ linear,
12
+ multi_step,
13
+ one_cycle,
14
+ polynomial,
15
+ step_decay,
16
+ )
17
+
18
+ __all__ = [
19
+ "Schedule",
20
+ "constant",
21
+ "cosine",
22
+ "cosine_restarts",
23
+ "exponential",
24
+ "inverse_sqrt",
25
+ "linear",
26
+ "multi_step",
27
+ "one_cycle",
28
+ "polynomial",
29
+ "sample",
30
+ "sequential",
31
+ "step_decay",
32
+ "with_warmup",
33
+ ]
34
+ __version__ = "0.1.0"
lrsched/_types.py ADDED
@@ -0,0 +1,4 @@
1
+ from collections.abc import Callable
2
+
3
+ # A schedule maps a non-negative integer step to a learning rate.
4
+ Schedule = Callable[[int], float]
lrsched/_validate.py ADDED
@@ -0,0 +1,19 @@
1
+ from math import isfinite
2
+
3
+
4
+ def check_finite(name: str, value: float) -> float:
5
+ if not isfinite(value):
6
+ raise ValueError(f"{name} must be a finite number, received {value!r}")
7
+ return value
8
+
9
+
10
+ def check_positive_int(name: str, value: int) -> int:
11
+ if not isinstance(value, int) or value <= 0:
12
+ raise ValueError(f"{name} must be a positive integer, received {value!r}")
13
+ return value
14
+
15
+
16
+ def check_step(step: int) -> int:
17
+ if not isinstance(step, int) or step < 0:
18
+ raise ValueError(f"step must be a non-negative integer, received {step!r}")
19
+ return step
lrsched/compose.py ADDED
@@ -0,0 +1,48 @@
1
+ from collections.abc import Sequence
2
+
3
+ from ._types import Schedule
4
+ from ._validate import check_finite, check_positive_int, check_step
5
+
6
+
7
+ def with_warmup(schedule: Schedule, *, warmup_steps: int, start_lr: float) -> Schedule:
8
+ """Prepend a linear warmup from start_lr to the wrapped schedule's value at step 0.
9
+ After warmup, the wrapped schedule runs with its step counted from the warmup end."""
10
+ check_positive_int("warmup_steps", warmup_steps)
11
+ check_finite("start_lr", start_lr)
12
+ target = schedule(0)
13
+
14
+ def wrapped(step: int) -> float:
15
+ check_step(step)
16
+ if step < warmup_steps:
17
+ return start_lr + (target - start_lr) * (step / warmup_steps)
18
+ return schedule(step - warmup_steps)
19
+
20
+ return wrapped
21
+
22
+
23
+ def sequential(phases: Sequence[tuple[int, Schedule]]) -> Schedule:
24
+ """Run schedules back to back. Each phase is (duration, schedule), and within a phase
25
+ the schedule sees a step counted from that phase's start. Past the final phase, the
26
+ last phase's final value is held."""
27
+ if len(phases) == 0:
28
+ raise ValueError("sequential requires at least one phase")
29
+ for duration, _ in phases:
30
+ check_positive_int("phase duration", duration)
31
+
32
+ def wrapped(step: int) -> float:
33
+ check_step(step)
34
+ offset = 0
35
+ for duration, schedule in phases:
36
+ if step < offset + duration:
37
+ return schedule(step - offset)
38
+ offset += duration
39
+ last_duration, last_schedule = phases[-1]
40
+ return last_schedule(last_duration - 1)
41
+
42
+ return wrapped
43
+
44
+
45
+ def sample(schedule: Schedule, *, num_steps: int) -> list[float]:
46
+ """Evaluate a schedule at steps 0..num_steps-1, useful for plotting or testing."""
47
+ check_positive_int("num_steps", num_steps)
48
+ return [schedule(i) for i in range(num_steps)]
lrsched/py.typed ADDED
File without changes
lrsched/schedules.py ADDED
@@ -0,0 +1,170 @@
1
+ from collections.abc import Sequence
2
+ from math import cos, pi
3
+
4
+ from ._types import Schedule
5
+ from ._validate import check_finite, check_positive_int, check_step
6
+
7
+
8
+ def constant(*, lr: float) -> Schedule:
9
+ """A flat learning rate."""
10
+ check_finite("lr", lr)
11
+
12
+ def schedule(step: int) -> float:
13
+ check_step(step)
14
+ return lr
15
+
16
+ return schedule
17
+
18
+
19
+ def step_decay(*, base_lr: float, gamma: float, step_size: int) -> Schedule:
20
+ """Multiply by gamma every step_size steps."""
21
+ check_finite("base_lr", base_lr)
22
+ check_finite("gamma", gamma)
23
+ check_positive_int("step_size", step_size)
24
+
25
+ def schedule(step: int) -> float:
26
+ check_step(step)
27
+ return base_lr * gamma ** (step // step_size)
28
+
29
+ return schedule
30
+
31
+
32
+ def multi_step(*, base_lr: float, milestones: Sequence[int], gamma: float) -> Schedule:
33
+ """Multiply by gamma at each milestone step."""
34
+ check_finite("base_lr", base_lr)
35
+ check_finite("gamma", gamma)
36
+ ordered = sorted(milestones)
37
+ for m in ordered:
38
+ check_positive_int("milestone", m)
39
+
40
+ def schedule(step: int) -> float:
41
+ check_step(step)
42
+ drops = sum(1 for m in ordered if step >= m)
43
+ return base_lr * gamma**drops
44
+
45
+ return schedule
46
+
47
+
48
+ def exponential(*, base_lr: float, gamma: float) -> Schedule:
49
+ """Decay by a constant factor gamma each step: base_lr * gamma ** step."""
50
+ check_finite("base_lr", base_lr)
51
+ check_finite("gamma", gamma)
52
+
53
+ def schedule(step: int) -> float:
54
+ check_step(step)
55
+ return base_lr * gamma**step
56
+
57
+ return schedule
58
+
59
+
60
+ def polynomial(*, base_lr: float, end_lr: float, total_steps: int, power: float) -> Schedule:
61
+ """Polynomial decay from base_lr to end_lr over total_steps, then hold end_lr."""
62
+ check_finite("base_lr", base_lr)
63
+ check_finite("end_lr", end_lr)
64
+ check_positive_int("total_steps", total_steps)
65
+ check_finite("power", power)
66
+
67
+ def schedule(step: int) -> float:
68
+ check_step(step)
69
+ t = min(step, total_steps)
70
+ remaining = 1.0 - t / total_steps
71
+ return float(end_lr + (base_lr - end_lr) * remaining**power)
72
+
73
+ return schedule
74
+
75
+
76
+ def linear(*, base_lr: float, end_lr: float, total_steps: int) -> Schedule:
77
+ """Linear interpolation from base_lr to end_lr over total_steps, then hold end_lr."""
78
+ check_finite("base_lr", base_lr)
79
+ check_finite("end_lr", end_lr)
80
+ check_positive_int("total_steps", total_steps)
81
+
82
+ def schedule(step: int) -> float:
83
+ check_step(step)
84
+ t = min(step, total_steps)
85
+ return base_lr + (end_lr - base_lr) * (t / total_steps)
86
+
87
+ return schedule
88
+
89
+
90
+ def cosine(*, base_lr: float, min_lr: float, total_steps: int) -> Schedule:
91
+ """Cosine annealing from base_lr at step 0 to min_lr at total_steps, then hold."""
92
+ check_finite("base_lr", base_lr)
93
+ check_finite("min_lr", min_lr)
94
+ check_positive_int("total_steps", total_steps)
95
+
96
+ def schedule(step: int) -> float:
97
+ check_step(step)
98
+ t = min(step, total_steps)
99
+ return min_lr + 0.5 * (base_lr - min_lr) * (1.0 + cos(pi * t / total_steps))
100
+
101
+ return schedule
102
+
103
+
104
+ def cosine_restarts(*, base_lr: float, min_lr: float, period: int, t_mult: int) -> Schedule:
105
+ """Cosine annealing with warm restarts (SGDR). Each cycle resets to base_lr; cycle
106
+ length is multiplied by t_mult after each restart (t_mult = 1 keeps it periodic)."""
107
+ check_finite("base_lr", base_lr)
108
+ check_finite("min_lr", min_lr)
109
+ check_positive_int("period", period)
110
+ check_positive_int("t_mult", t_mult)
111
+
112
+ def schedule(step: int) -> float:
113
+ check_step(step)
114
+ start = 0
115
+ current = period
116
+ while step >= start + current:
117
+ start += current
118
+ current *= t_mult
119
+ t = step - start
120
+ return min_lr + 0.5 * (base_lr - min_lr) * (1.0 + cos(pi * t / current))
121
+
122
+ return schedule
123
+
124
+
125
+ def inverse_sqrt(*, base_lr: float, warmup_steps: int) -> Schedule:
126
+ """Transformer schedule: linear warmup to base_lr at warmup_steps, then decay by the
127
+ inverse square root of the step."""
128
+ check_finite("base_lr", base_lr)
129
+ check_positive_int("warmup_steps", warmup_steps)
130
+
131
+ def schedule(step: int) -> float:
132
+ check_step(step)
133
+ if step < warmup_steps:
134
+ return base_lr * step / warmup_steps
135
+ return float(base_lr * (warmup_steps / step) ** 0.5)
136
+
137
+ return schedule
138
+
139
+
140
+ def one_cycle(
141
+ *, max_lr: float, total_steps: int, pct_start: float, initial_div: float, final_div: float
142
+ ) -> Schedule:
143
+ """One-cycle policy: cosine ramp up to max_lr over the first pct_start of training,
144
+ then cosine anneal down to max_lr / final_div. Starts at max_lr / initial_div."""
145
+ check_finite("max_lr", max_lr)
146
+ check_positive_int("total_steps", total_steps)
147
+ check_finite("pct_start", pct_start)
148
+ check_finite("initial_div", initial_div)
149
+ check_finite("final_div", final_div)
150
+ if not 0.0 < pct_start < 1.0:
151
+ raise ValueError(f"pct_start must be between 0 and 1, received {pct_start!r}")
152
+ if initial_div <= 0.0 or final_div <= 0.0:
153
+ raise ValueError("initial_div and final_div must be positive")
154
+ if total_steps < 2:
155
+ raise ValueError("total_steps must be at least 2 for one_cycle")
156
+
157
+ warmup = min(max(1, round(pct_start * total_steps)), total_steps - 1)
158
+ start_lr = max_lr / initial_div
159
+ end_lr = max_lr / final_div
160
+
161
+ def schedule(step: int) -> float:
162
+ check_step(step)
163
+ if step <= warmup:
164
+ frac = step / warmup
165
+ return start_lr + 0.5 * (max_lr - start_lr) * (1.0 - cos(pi * frac))
166
+ t = min(step, total_steps)
167
+ frac = (t - warmup) / (total_steps - warmup)
168
+ return end_lr + 0.5 * (max_lr - end_lr) * (1.0 + cos(pi * frac))
169
+
170
+ return schedule
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: lrsched
3
+ Version: 0.1.0
4
+ Summary: Framework-agnostic learning-rate schedules as pure functions, in Python with zero dependencies.
5
+ Project-URL: Homepage, https://github.com/amaar-mc/lrsched
6
+ Project-URL: Repository, https://github.com/amaar-mc/lrsched
7
+ Project-URL: Issues, https://github.com/amaar-mc/lrsched/issues
8
+ Project-URL: Changelog, https://github.com/amaar-mc/lrsched/blob/main/CHANGELOG.md
9
+ Author: Amaar Chughtai
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 Amaar Chughtai
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: cosine-annealing,deep-learning,learning-rate,lr-schedule,machine-learning,one-cycle,scheduler,sgdr,training,warmup
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: Intended Audience :: Science/Research
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
43
+ Classifier: Typing :: Typed
44
+ Requires-Python: >=3.10
45
+ Provides-Extra: dev
46
+ Requires-Dist: hypothesis>=6; extra == 'dev'
47
+ Requires-Dist: mypy>=1.11; extra == 'dev'
48
+ Requires-Dist: pytest>=8; extra == 'dev'
49
+ Requires-Dist: ruff>=0.6; extra == 'dev'
50
+ Description-Content-Type: text/markdown
51
+
52
+ # lrsched
53
+
54
+ <p align="center">
55
+ <img src="assets/logo.png" alt="lrsched logo" width="160">
56
+ </p>
57
+
58
+ [![PyPI](https://img.shields.io/pypi/v/lrsched)](https://pypi.org/project/lrsched/)
59
+ [![CI](https://github.com/amaar-mc/lrsched/actions/workflows/ci.yml/badge.svg)](https://github.com/amaar-mc/lrsched/actions/workflows/ci.yml)
60
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
61
+
62
+ Framework-agnostic learning-rate schedules as pure functions, in Python with zero dependencies. Each schedule maps a step to a learning rate, so it works in any training loop or framework, or none.
63
+
64
+ ## Install
65
+
66
+ ```sh
67
+ pip install lrsched
68
+ ```
69
+
70
+ ## 30-second example
71
+
72
+ ```python
73
+ from lrsched import cosine, with_warmup, sample
74
+
75
+ schedule = with_warmup(
76
+ cosine(base_lr=1e-3, min_lr=1e-5, total_steps=1000),
77
+ warmup_steps=100,
78
+ start_lr=0.0,
79
+ )
80
+
81
+ lr = schedule(250) # learning rate at step 250
82
+ curve = sample(schedule, num_steps=1000) # the whole curve, for plotting or logging
83
+ ```
84
+
85
+ A schedule is just `Callable[[int], float]`. Plug `schedule(step)` into your optimizer
86
+ however your framework expects, or use it to drive a plain training loop.
87
+
88
+ ## Why this exists
89
+
90
+ Every learning-rate scheduler is tied to a framework: `torch.optim.lr_scheduler`,
91
+ `timm`, `transformers`, or `optax` for JAX. If you write a custom loop, use a
92
+ non-PyTorch stack, or just want to plot a schedule, you end up pasting a `LambdaLR`
93
+ snippet. `lrsched` is a small, dependency-free library where each schedule is a pure
94
+ function, easy to test, plot, log, and reuse anywhere.
95
+
96
+ ## Comparison
97
+
98
+ | | lrsched | torch / timm | optax |
99
+ |---|:---:|:---:|:---:|
100
+ | Framework | none | PyTorch | JAX |
101
+ | Pure step to lr function | yes | no (optimizer-bound) | partial |
102
+ | Zero dependencies | yes | no | no |
103
+ | Composable warmup and phases | yes | partial | yes |
104
+
105
+ ## Schedules
106
+
107
+ - `constant`, `step_decay`, `multi_step`, `exponential`
108
+ - `linear`, `polynomial`
109
+ - `cosine`, `cosine_restarts` (SGDR)
110
+ - `inverse_sqrt` (Transformer)
111
+ - `one_cycle`
112
+
113
+ ## Composition
114
+
115
+ - `with_warmup(schedule, ...)` prepends a linear warmup.
116
+ - `sequential(phases)` runs schedules back to back.
117
+ - `sample(schedule, num_steps=...)` evaluates a schedule over a range.
118
+
119
+ Parameters are required keyword arguments, so every schedule reads explicitly at the call
120
+ site. Schedules hold their final value past the end rather than erroring, and a negative
121
+ step raises.
122
+
123
+ ## Examples
124
+
125
+ ```sh
126
+ python examples/schedules.py
127
+ ```
128
+
129
+ ## Testing
130
+
131
+ ```sh
132
+ pip install -e ".[dev]"
133
+ pytest
134
+ ```
135
+
136
+ Tests cover the exact value of each schedule at known steps, schedule-specific shapes
137
+ (restarts, the one-cycle peak, the warmup handoff), and invariants checked with
138
+ Hypothesis (cosine stays within bounds, warmup is monotone).
139
+
140
+ ## Contributing
141
+
142
+ Issues and pull requests are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md).
143
+
144
+ ## License
145
+
146
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,10 @@
1
+ lrsched/__init__.py,sha256=IIce4y2wDGfX20N5mDrg4n_auf5XTz8Y0gE13yvORDs,640
2
+ lrsched/_types.py,sha256=juJaHUF6QXBdcDR0x5PS9gkdqB9NhiVkmL999gNHCKA,138
3
+ lrsched/_validate.py,sha256=5GC6kEL4Qx7BIM_tzbD-PDMJfOuuOQFvIBmblAw6sQA,592
4
+ lrsched/compose.py,sha256=yhFPuT3FJQ5ierXeNAT_OGU-IfWAT-KjxU9K6rvXIhE,1835
5
+ lrsched/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ lrsched/schedules.py,sha256=gx5K_mc1Wu6HzAhjeJk_FAZiKJx1n21eWkuyZ1gRXKc,5761
7
+ lrsched-0.1.0.dist-info/METADATA,sha256=oXungvasmPsK9ef61NjjOhEqaC6cFlaLouR2hlXMdxQ,5511
8
+ lrsched-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ lrsched-0.1.0.dist-info/licenses/LICENSE,sha256=NORTVJb4x206RXQkpZt_vpj8I7aZL6Vn26BvTOq6J40,1071
10
+ lrsched-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amaar Chughtai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.