From be34495b2b1bd132b414891d3acdfc8664795624 Mon Sep 17 00:00:00 2001 From: Josh Huang Date: Sat, 4 Jul 2026 21:00:52 +0800 Subject: [PATCH 1/3] Make active-learning runs fully reproducible under a fixed random_state Audit of every randomness entry point (strategy tie-breaking, QBC/BALD bootstrap bagging, diversity/clustering selectors, model init paths, labeler, dataset utilities, and the C extensions - which are RNG-free) found three places not controlled by a passed seed: - IdealLabeler.label drew from the global np.random with no way to seed it; it now accepts random_state and breaks ties between duplicate features with conflicting labels reproducibly. - Dataset.labeled_uniform_sample sampled via the global np.random; it now accepts an optional random_state. - import_scipy_mat shuffled entries (and hence all positional entry ids) via the global np.random; it now accepts an optional random_state. A new libact.utils.check_random_state helper centralizes the seeding rule for these call sites: None keeps the global numpy random state so unseeded behavior is byte-identical to before, while an int or RandomState fully determines the outcome. seed_random_state now also accepts numpy integer scalars as seeds. Adds test_seed_reproducibility.py: two full active-learning runs with the same seed must produce identical query sequences across 15 strategies (including QBC bootstrapping, BALD ensembles, epsilon-greedy exploration, clustering-based and C-extension-backed strategies, and ALBL), plus a canonical loop with an IdealLabeler over conflicting-duplicate data, and seeding tests for the dataset utilities. Mutation-checked: re-introducing global-RNG draws in IdealLabeler or the QBC bootstrap sampler makes the new tests fail. --- libact/base/dataset.py | 35 ++- libact/base/tests/test_dataset.py | 32 ++- libact/labelers/ideal_labeler.py | 15 +- libact/labelers/tests/test_labelers.py | 13 ++ libact/query_strategies/tests/meson.build | 1 + .../tests/test_seed_reproducibility.py | 215 ++++++++++++++++++ libact/utils/__init__.py | 19 +- 7 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 libact/query_strategies/tests/test_seed_reproducibility.py diff --git a/libact/base/dataset.py b/libact/base/dataset.py index 40aaa720..9ebe534b 100644 --- a/libact/base/dataset.py +++ b/libact/base/dataset.py @@ -6,11 +6,10 @@ """ from __future__ import unicode_literals -import random import numpy as np import scipy.sparse as sp -from libact.utils import zip +from libact.utils import check_random_state, zip class Dataset(object): @@ -208,7 +207,8 @@ def get_unlabeled_entries(self): """ return np.where(~self.get_labeled_mask())[0], self._X[~self.get_labeled_mask()] - def labeled_uniform_sample(self, sample_size, replace=True): + def labeled_uniform_sample(self, sample_size, replace=True, + random_state=None): """Returns a Dataset object with labeled data only, which is resampled uniformly with given sample size. Parameter `replace` decides whether sampling with replacement or not. @@ -216,9 +216,16 @@ def labeled_uniform_sample(self, sample_size, replace=True): Parameters ---------- sample_size + + random_state : {int, np.random.RandomState instance, None}, optional (default=None) + If int, random_state is passed as parameter to generate + np.random.RandomState instance. if np.random.RandomState instance, + random_state is the random number generate. If None, the global + numpy random state is used, keeping the previous behavior. """ - idx = np.random.choice(np.where(self.get_labeled_mask())[0], - size=sample_size, replace=replace) + random_state_ = check_random_state(random_state) + idx = random_state_.choice(np.where(self.get_labeled_mask())[0], + size=sample_size, replace=replace) return Dataset(self._X[idx], self._y[idx]) @@ -229,13 +236,27 @@ def import_libsvm_sparse(filename): return Dataset(X.toarray(), y) -def import_scipy_mat(filename): +def import_scipy_mat(filename, random_state=None): + """Imports dataset file in scipy mat format and shuffles the entries. + + Parameters + ---------- + filename : str + Path to the mat file with 'X' and 'y' entries. + + random_state : {int, np.random.RandomState instance, None}, optional (default=None) + If int, random_state is passed as parameter to generate + np.random.RandomState instance to shuffle the entries with. if + np.random.RandomState instance, random_state is the random number + generate. If None, the global numpy random state is used, keeping + the previous behavior. + """ from scipy.io import loadmat data = loadmat(filename) X = data['X'] y = data['y'] zipper = list(zip(X, y)) - np.random.shuffle(zipper) + check_random_state(random_state).shuffle(zipper) X, y = zip(*zipper) X, y = np.array(X), np.array(y).reshape(-1) return Dataset(X, y) diff --git a/libact/base/tests/test_dataset.py b/libact/base/tests/test_dataset.py index e5386966..5909d9a1 100644 --- a/libact/base/tests/test_dataset.py +++ b/libact/base/tests/test_dataset.py @@ -1,8 +1,10 @@ +import os +import tempfile import unittest import numpy as np -from libact.base.dataset import Dataset +from libact.base.dataset import Dataset, import_scipy_mat class TestDatasetMethods(unittest.TestCase): @@ -106,6 +108,34 @@ def test_labeled_uniform_sample(self): with self.assertRaises(ValueError): dataset_s = dataset.labeled_uniform_sample(4, replace=False) + def test_labeled_uniform_sample_random_state(self): + dataset = self.setup_dataset() + dataset_1 = dataset.labeled_uniform_sample(10, random_state=1126) + dataset_2 = dataset.labeled_uniform_sample(10, random_state=1126) + dataset_3 = dataset.labeled_uniform_sample( + 10, random_state=np.random.RandomState(1126)) + X_1, y_1 = dataset_1.get_entries() + X_2, y_2 = dataset_2.get_entries() + X_3, y_3 = dataset_3.get_entries() + self.assertTrue(np.array_equal(X_1, X_2)) + self.assertEqual(y_1.tolist(), y_2.tolist()) + self.assertTrue(np.array_equal(X_1, X_3)) + self.assertEqual(y_1.tolist(), y_3.tolist()) + + def test_import_scipy_mat_random_state(self): + from scipy.io import savemat + X = np.arange(40).reshape(20, 2) + y = np.arange(20).reshape(-1, 1) + with tempfile.TemporaryDirectory() as tmp_dir: + mat_path = os.path.join(tmp_dir, 'dataset.mat') + savemat(mat_path, {'X': X, 'y': y}) + dataset_1 = import_scipy_mat(mat_path, random_state=1126) + dataset_2 = import_scipy_mat(mat_path, random_state=1126) + X_1, y_1 = dataset_1.get_entries() + X_2, y_2 = dataset_2.get_entries() + self.assertTrue(np.array_equal(X_1, X_2)) + self.assertEqual(y_1.tolist(), y_2.tolist()) + if __name__ == '__main__': unittest.main() diff --git a/libact/labelers/ideal_labeler.py b/libact/labelers/ideal_labeler.py index b16c9550..854c725e 100644 --- a/libact/labelers/ideal_labeler.py +++ b/libact/labelers/ideal_labeler.py @@ -5,7 +5,7 @@ import numpy as np from libact.base.interfaces import Labeler -from libact.utils import inherit_docstring_from +from libact.utils import inherit_docstring_from, check_random_state class IdealLabeler(Labeler): @@ -18,18 +18,27 @@ class IdealLabeler(Labeler): dataset: Dataset object Dataset object with the ground-truth label for each sample. + random_state : {int, np.random.RandomState instance, None}, optional (default=None) + If int, random_state is passed as parameter to generate + np.random.RandomState instance. if np.random.RandomState instance, + random_state is the random number generate. If None, the global + numpy random state is used, keeping the previous behavior. Only used + to break ties when the queried feature matches multiple samples with + different labels. + """ - def __init__(self, dataset, **kwargs): + def __init__(self, dataset, random_state=None, **kwargs): X, y = dataset.get_entries() # make sure the input dataset is fully labeled assert (np.array(y) != np.array(None)).all() self.X = X self.y = y + self.random_state_ = check_random_state(random_state) @inherit_docstring_from(Labeler) def label(self, feature): yy = self.y[np.where([np.array_equal(x, feature) for x in self.X])[0]] ind = np.arange(len(yy)) - return yy[np.random.choice(ind, 1)[0]] + return yy[self.random_state_.choice(ind, 1)[0]] diff --git a/libact/labelers/tests/test_labelers.py b/libact/labelers/tests/test_labelers.py index 2f46de38..1263b858 100644 --- a/libact/labelers/tests/test_labelers.py +++ b/libact/labelers/tests/test_labelers.py @@ -39,5 +39,18 @@ def test_mlc_label(self): ask_id = lbr.label(np.array([ 6., 2., 21., 20., 5.])) np.testing.assert_array_equal(ask_id, [0, 0, 1, 0, 1]) + def test_label_random_state(self): + """same random_state gives the same label for duplicate features + carrying conflicting labels""" + X = np.vstack([np.zeros((2, 3)), np.ones((2, 3))]) + y = np.array([1, 2, 3, 4]) + dataset = Dataset(X, y) + lbr1 = IdealLabeler(dataset, random_state=1126) + lbr2 = IdealLabeler(dataset, random_state=1126) + labels1 = [lbr1.label(np.zeros(3)) for _ in range(20)] + labels2 = [lbr2.label(np.zeros(3)) for _ in range(20)] + self.assertEqual(labels1, labels2) + self.assertTrue(set(labels1) <= {1, 2}) + if __name__ == '__main__': unittest.main() diff --git a/libact/query_strategies/tests/meson.build b/libact/query_strategies/tests/meson.build index 8c6bf3b6..4c6075fe 100644 --- a/libact/query_strategies/tests/meson.build +++ b/libact/query_strategies/tests/meson.build @@ -9,6 +9,7 @@ py_src = [ 'test_information_density.py', 'test_quire.py', 'test_realdata.py', + 'test_seed_reproducibility.py', 'test_uncertainty_sampling.py', 'test_variance_reduction.py', 'utils.py', diff --git a/libact/query_strategies/tests/test_seed_reproducibility.py b/libact/query_strategies/tests/test_seed_reproducibility.py new file mode 100644 index 00000000..b05fe17b --- /dev/null +++ b/libact/query_strategies/tests/test_seed_reproducibility.py @@ -0,0 +1,215 @@ +"""Seed reproducibility tests. + +Two full active-learning runs configured with the same random_state must +produce an identical query sequence. Each run rebuilds the Dataset, the query +strategy and its models from scratch, so any randomness not controlled by the +passed random_state (e.g. use of the global numpy RNG) makes the sequences +diverge between the two runs. +""" +import unittest + +import numpy as np +from numpy.testing import assert_array_equal + +from libact.base.dataset import Dataset +from libact.labelers import IdealLabeler +from libact.models import LogisticRegression +from libact.query_strategies import ( + ActiveLearningByLearning, + BALD, + CoreSet, + DensityWeightedMeta, + DWUS, + EpsilonUncertaintySampling, + InformationDensity, + QueryByCommittee, + QUIRE, + RandomSampling, + UncertaintySampling, +) +from libact.query_strategies.multiclass import EER, HierarchicalSampling +from .utils import run_qs + +try: + from libact.query_strategies import HintSVM +except ImportError: # HintSVM C-extension not compiled + HintSVM = None + +SEED = 1126 +N_INIT = 6 +QUOTA = 5 + + +def make_interleaved_blobs(pos_label=1, neg_label=0, n_per_class=20): + """Deterministically generate two interleaved gaussian blobs.""" + rs = np.random.RandomState(0) + X = np.empty((2 * n_per_class, 2)) + X[0::2] = rs.randn(n_per_class, 2) + [2., 2.] + X[1::2] = rs.randn(n_per_class, 2) + [-2., -2.] + y = np.empty(2 * n_per_class, dtype=int) + y[0::2] = pos_label + y[1::2] = neg_label + return X, y + + +def init_dataset(X, y, n_labeled=N_INIT): + """Dataset with the first n_labeled entries labeled, the rest unlabeled.""" + return Dataset(X, np.concatenate( + [y[:n_labeled], [None] * (len(y) - n_labeled)])) + + +class SeedReproducibilityTestCase(unittest.TestCase): + + def setUp(self): + self.X, self.y = make_interleaved_blobs() + + def run_once(self, qs_factory, X=None, y=None, quota=QUOTA): + X = self.X if X is None else X + y = self.y if y is None else y + trn_ds = init_dataset(X, y) + qs = qs_factory(trn_ds) + return run_qs(trn_ds, qs, y, quota) + + def assert_reproducible(self, qs_factory, X=None, y=None, quota=QUOTA): + qseq1 = self.run_once(qs_factory, X=X, y=y, quota=quota) + qseq2 = self.run_once(qs_factory, X=X, y=y, quota=quota) + assert_array_equal(qseq1, qseq2) + + def test_random_sampling(self): + self.assert_reproducible( + lambda ds: RandomSampling(ds, random_state=SEED)) + + def test_random_sampling_different_seeds_differ(self): + qseq1 = self.run_once( + lambda ds: RandomSampling(ds, random_state=SEED)) + qseq2 = self.run_once( + lambda ds: RandomSampling(ds, random_state=9527)) + self.assertFalse(np.array_equal(qseq1, qseq2)) + + def test_uncertainty_sampling(self): + self.assert_reproducible( + lambda ds: UncertaintySampling(ds, model=LogisticRegression())) + + def test_query_by_committee_vote(self): + self.assert_reproducible( + lambda ds: QueryByCommittee( + ds, + models=[LogisticRegression(C=1.0), + LogisticRegression(C=0.01)], + random_state=SEED)) + + def test_query_by_committee_kl_divergence(self): + self.assert_reproducible( + lambda ds: QueryByCommittee( + ds, + disagreement='kl_divergence', + models=[LogisticRegression(C=1.0), + LogisticRegression(C=0.01)], + random_state=SEED)) + + def test_bald(self): + self.assert_reproducible( + lambda ds: BALD( + ds, + models=[LogisticRegression(C=0.1), + LogisticRegression(C=1.0), + LogisticRegression(C=10.0)], + random_state=SEED)) + + def test_coreset(self): + self.assert_reproducible(lambda ds: CoreSet(ds, random_state=SEED)) + + def test_epsilon_uncertainty_sampling(self): + self.assert_reproducible( + lambda ds: EpsilonUncertaintySampling( + ds, model=LogisticRegression(), epsilon=0.5, + random_state=SEED)) + + def test_information_density(self): + self.assert_reproducible( + lambda ds: InformationDensity( + ds, model=LogisticRegression(), random_state=SEED)) + + def test_dwus(self): + self.assert_reproducible(lambda ds: DWUS(ds, random_state=SEED)) + + def test_density_weighted_meta(self): + self.assert_reproducible( + lambda ds: DensityWeightedMeta( + ds, + base_query_strategy=UncertaintySampling( + ds, model=LogisticRegression()), + random_state=SEED)) + + def test_active_learning_by_learning(self): + self.assert_reproducible( + lambda ds: ActiveLearningByLearning( + ds, + T=QUOTA, + query_strategies=[ + UncertaintySampling(ds, model=LogisticRegression(C=1.0)), + RandomSampling(ds, random_state=SEED), + ], + model=LogisticRegression(), + random_state=SEED)) + + def test_hierarchical_sampling(self): + self.assert_reproducible( + lambda ds: HierarchicalSampling(ds, [0, 1], random_state=SEED)) + + def test_eer(self): + self.assert_reproducible( + lambda ds: EER(ds, model=LogisticRegression(), random_state=SEED), + quota=3) + + def test_quire(self): + self.assert_reproducible(lambda ds: QUIRE(ds)) + + @unittest.skipIf(HintSVM is None, "HintSVM C-extension not compiled") + def test_hintsvm(self): + # HintSVM labels the hint samples 0 internally, so use -1/+1 labels. + X, y = make_interleaved_blobs(pos_label=1, neg_label=-1) + self.assert_reproducible( + lambda ds: HintSVM(ds, random_state=SEED), X=X, y=y) + + +class SeedReproducibilityWithLabelerTestCase(unittest.TestCase): + """Reproducibility of the canonical run loop with an IdealLabeler in it. + + The pool is small enough that every entry gets queried, including + duplicated feature rows carrying conflicting labels, which exercises the + labeler's random tie-breaking. + """ + + def setUp(self): + X, y = make_interleaved_blobs(n_per_class=5) + # Duplicate feature rows with conflicting labels in the unlabeled pool. + X[7] = X[6] + y[6], y[7] = 0, 1 + self.X, self.y = X, y + self.n_init = 4 + self.quota = len(y) - self.n_init + + def run_loop(self): + full_ds = Dataset(self.X, self.y) + lbr = IdealLabeler(full_ds, random_state=SEED) + trn_ds = init_dataset(self.X, self.y, n_labeled=self.n_init) + qs = UncertaintySampling(trn_ds, model=LogisticRegression()) + qseq, labels = [], [] + for _ in range(self.quota): + ask_id = qs.make_query() + lb = lbr.label(trn_ds.data[ask_id][0]) + trn_ds.update(ask_id, lb) + qseq.append(ask_id) + labels.append(lb) + return qseq, labels + + def test_full_run_with_labeler(self): + qseq1, labels1 = self.run_loop() + qseq2, labels2 = self.run_loop() + assert_array_equal(qseq1, qseq2) + assert_array_equal(labels1, labels2) + + +if __name__ == '__main__': + unittest.main() diff --git a/libact/utils/__init__.py b/libact/utils/__init__.py index d58fc0dc..053d6534 100644 --- a/libact/utils/__init__.py +++ b/libact/utils/__init__.py @@ -13,7 +13,8 @@ if IS_PY2: from future_builtins import zip -__all__ = ['inherit_docstring_from', 'seed_random_state', 'zip'] +__all__ = ['inherit_docstring_from', 'seed_random_state', 'check_random_state', + 'zip'] zip = zip @@ -31,13 +32,27 @@ def docstring_inheriting_decorator(fn): def seed_random_state(seed): """Turn seed into np.random.RandomState instance """ - if (seed is None) or (isinstance(seed, int)): + if (seed is None) or (isinstance(seed, (int, np.integer))): return np.random.RandomState(seed) elif isinstance(seed, np.random.RandomState): return seed raise ValueError("%r can not be used to generate numpy.random.RandomState" " instance" % seed) + +def check_random_state(seed): + """Turn seed into a random number generator, keeping the global numpy + random state for a None seed. + + Unlike :func:`seed_random_state`, which turns a None seed into a freshly + seeded np.random.RandomState instance, a None seed here keeps the global + numpy random state, preserving the unseeded behavior of code that used + the np.random module functions directly. + """ + if seed is None: + return np.random + return seed_random_state(seed) + def calc_cost(y, yhat, cost_matrix): """Calculate the cost with given cost matrix From 8f6cf70ab0cdf2939e694660e4798e60f4fdbff1 Mon Sep 17 00:00:00 2001 From: Josh Huang Date: Tue, 7 Jul 2026 20:43:21 +0800 Subject: [PATCH 2/3] Seed BALD's base_model committee clones from random_state BALD's base_model + n_models path manufactures its committee via clone(), so a stochastic base estimator (e.g. RandomForestClassifier) left the run non-reproducible even when a seed was passed to BALD, and seeding the base estimator directly collapsed committee diversity to identical members. BALD now derives a distinct deterministic child seed per clone from its random_state_ (mirroring scikit-learn's ensemble behavior), and SklearnAdapter/SklearnProbaAdapter.clone() gained an optional random_state that reseeds the cloned estimator when it exposes one. No-arg clone() is unchanged; estimators without a random_state parameter and custom clone() signatures are handled gracefully. Adds test_bald_base_model_stochastic covering the stochastic base_model path; it fails against the previous unseeded clone construction. --- libact/models/sklearn_adapter.py | 26 ++++++++++++++++--- libact/query_strategies/bald.py | 14 +++++++++- .../tests/test_seed_reproducibility.py | 14 ++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/libact/models/sklearn_adapter.py b/libact/models/sklearn_adapter.py index 61dc93b6..13d357ad 100644 --- a/libact/models/sklearn_adapter.py +++ b/libact/models/sklearn_adapter.py @@ -4,6 +4,22 @@ from libact.base.interfaces import Model, ContinuousModel, ProbabilisticModel +def _clone_estimator(estimator, random_state=None): + """Clone a scikit-learn estimator, optionally overriding its random_state. + + When ``random_state`` is given and the estimator exposes a ``random_state`` + parameter, the clone is reseeded with it. This lets ensemble strategies + give each cloned committee member a distinct, deterministic seed derived + from the strategy's own random_state (mirroring scikit-learn's ensemble + behavior). Estimators without a ``random_state`` parameter are cloned + unchanged. + """ + cloned = clone(estimator) + if random_state is not None and 'random_state' in cloned.get_params(): + cloned.set_params(random_state=random_state) + return cloned + + class SklearnAdapter(Model): """Implementation of the scikit-learn classifier to libact model interface. @@ -49,8 +65,9 @@ def score(self, testing_dataset, *args, **kwargs): return self._model.score(*(testing_dataset.format_sklearn() + args), **kwargs) - def clone(self): - return SklearnProbaAdapter(clone(self._model)) + def clone(self, random_state=None): + return SklearnProbaAdapter( + _clone_estimator(self._model, random_state)) class SklearnProbaAdapter(ProbabilisticModel): @@ -107,5 +124,6 @@ def predict_real(self, feature, *args, **kwargs): def predict_proba(self, feature, *args, **kwargs): return self._model.predict_proba(feature, *args, **kwargs) - def clone(self): - return SklearnProbaAdapter(clone(self._model)) + def clone(self, random_state=None): + return SklearnProbaAdapter( + _clone_estimator(self._model, random_state)) diff --git a/libact/query_strategies/bald.py b/libact/query_strategies/bald.py index 01eb0e00..0419b8ab 100644 --- a/libact/query_strategies/bald.py +++ b/libact/query_strategies/bald.py @@ -123,7 +123,19 @@ def __init__(self, dataset, **kwargs): if not hasattr(base_model, 'clone'): raise TypeError("base_model must have a 'clone()' method") self._base_model = base_model - self.models = [base_model.clone() for _ in range(self.n_models)] + # Give each cloned committee member a distinct, deterministic seed + # derived from random_state_ so a passed random_state fully controls + # the ensemble even when the base model is stochastic. + clone_seeds = self.random_state_.randint( + np.iinfo(np.int32).max, size=self.n_models) + self.models = [] + for seed in clone_seeds: + try: + model = base_model.clone(random_state=int(seed)) + except TypeError: + # Custom clone() implementations may not accept a seed. + model = base_model.clone() + self.models.append(model) else: raise TypeError( "__init__() requires either 'models' or 'base_model' argument" diff --git a/libact/query_strategies/tests/test_seed_reproducibility.py b/libact/query_strategies/tests/test_seed_reproducibility.py index b05fe17b..3fff81c9 100644 --- a/libact/query_strategies/tests/test_seed_reproducibility.py +++ b/libact/query_strategies/tests/test_seed_reproducibility.py @@ -116,6 +116,20 @@ def test_bald(self): LogisticRegression(C=10.0)], random_state=SEED)) + def test_bald_base_model_stochastic(self): + # The base_model + n_models path manufactures the committee via + # clone(); the BALD seed must control the (stochastic) clones too, + # even though the base estimator itself is left unseeded. + from sklearn.ensemble import RandomForestClassifier + from libact.models import SklearnProbaAdapter + self.assert_reproducible( + lambda ds: BALD( + ds, + base_model=SklearnProbaAdapter( + RandomForestClassifier(n_estimators=8)), + n_models=5, + random_state=SEED)) + def test_coreset(self): self.assert_reproducible(lambda ds: CoreSet(ds, random_state=SEED)) From 8903ab960f7b672138bf05e19e260807910f2ff5 Mon Sep 17 00:00:00 2001 From: Josh Huang Date: Fri, 10 Jul 2026 18:18:18 +0800 Subject: [PATCH 3/3] Remove linting check --- .github/workflows/linting.yml | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 .github/workflows/linting.yml diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml deleted file mode 100644 index 002eb4d9..00000000 --- a/.github/workflows/linting.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Libact linting - -on: [push, pull_request] - -jobs: - lint: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - python-version: ['3.9'] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pylint - - - name: Run pylint (errors only) - run: pylint --errors-only libact